Browse the library
Components / Data
Key facts
FreeProperty key facts row with icons and numbers that count up when scrolled into view.
components/facade/key-facts.tsx
"use client";
import * as React from "react";
import { animate, useInView, useReducedMotion } from "motion/react";
import { Bath, BedDouble, Calendar, DollarSign, Ruler, Trees, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface KeyFact {
label: string;
value: number;
icon?: LucideIcon;
prefix?: string;
suffix?: string;
/** Decimal places to show. */
decimals?: number;
/** Disable thousands separators (useful for years). */
noGrouping?: boolean;
/** Use compact notation, e.g. 1.25M. */
compact?: boolean;
}
export interface KeyFactsProps {
facts?: KeyFact[];
/** Duration of the count-up in seconds. */
duration?: number;
className?: string;
}
const defaultFacts: KeyFact[] = [
{ label: "List price", value: 1250000, icon: DollarSign, prefix: "$", compact: true, decimals: 2 },
{ label: "Bedrooms", value: 4, icon: BedDouble },
{ label: "Bathrooms", value: 3.5, icon: Bath, decimals: 1 },
{ label: "Interior", value: 3120, icon: Ruler, suffix: " sqft" },
{ label: "Lot size", value: 0.34, icon: Trees, suffix: " ac", decimals: 2 },
{ label: "Year built", value: 2019, icon: Calendar, noGrouping: true },
];
type FormatOptions = Pick<KeyFact, "value" | "decimals" | "compact" | "noGrouping">;
function format(fact: FormatOptions, n: number) {
if (fact.compact && Math.abs(fact.value) >= 1000) {
const abs = Math.abs(fact.value);
const div = abs >= 1_000_000 ? 1_000_000 : 1000;
const unit = abs >= 1_000_000 ? "M" : "K";
const scaled = n / div;
return `${scaled.toLocaleString("en-US", {
minimumFractionDigits: fact.decimals ?? 0,
maximumFractionDigits: fact.decimals ?? 0,
})}${unit}`;
}
return n.toLocaleString("en-US", {
minimumFractionDigits: fact.decimals ?? 0,
maximumFractionDigits: fact.decimals ?? 0,
useGrouping: !fact.noGrouping,
});
}
function CountUp({ fact, start, duration }: { fact: KeyFact; start: boolean; duration: number }) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLSpanElement>(null);
const { value, decimals, compact, noGrouping } = fact;
const from = noGrouping ? Math.round(value * 0.97) : 0;
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const opts: FormatOptions = { value, decimals, compact, noGrouping };
if (!start) {
el.textContent = format(opts, from);
return;
}
if (reduce) {
el.textContent = format(opts, value);
return;
}
const controls = animate(from, value, {
duration,
ease: [0.22, 1, 0.36, 1],
onUpdate: (v) => {
const factor = Math.pow(10, decimals ?? 0);
el.textContent = format(opts, compact ? v : Math.round(v * factor) / factor);
},
});
return () => controls.stop();
}, [start, reduce, value, decimals, compact, noGrouping, from, duration]);
return (
<span className="tabular-nums">
{fact.prefix}
<span ref={ref}>{format(fact, from)}</span>
{fact.suffix && <span className="text-[0.6em] font-medium text-muted-foreground">{fact.suffix}</span>}
</span>
);
}
export function KeyFacts({ facts = defaultFacts, duration = 1.4, className }: KeyFactsProps) {
const ref = React.useRef<HTMLDListElement>(null);
const inView = useInView(ref, { once: true, margin: "-60px 0px" });
return (
<dl
ref={ref}
className={cn(
"grid w-full grid-cols-2 overflow-hidden rounded-2xl border border-border bg-card text-card-foreground sm:grid-cols-3 lg:grid-cols-6",
"[&>div]:border-border [&>div]:border-b [&>div]:border-r",
className,
)}
>
{facts.map((f) => {
const Icon = f.icon;
return (
<div key={f.label} className="relative -mb-px -mr-px flex flex-col gap-3 p-4 sm:p-5">
<dt className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{Icon && (
<span className="grid size-7 place-items-center rounded-lg bg-accent text-accent-foreground">
<Icon className="size-3.5" aria-hidden="true" />
</span>
)}
{f.label}
</dt>
<dd className="font-display text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
<CountUp fact={f} start={inView} duration={duration} />
</dd>
</div>
);
})}
</dl>
);
}
export default KeyFacts;app/page.tsx
import { Bath, BedDouble, Calendar, DollarSign, Ruler, Trees } from "lucide-react";
import { KeyFacts } from "@/components/facade/key-facts";
export default function Demo() {
return (
<div className="w-full max-w-[880px]">
<div className="mb-4 flex flex-wrap items-end justify-between gap-2">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-brand">Just listed</p>
<p className="font-display text-xl font-semibold tracking-tight text-foreground">4417 Wildflower Ridge</p>
<p className="text-sm text-muted-foreground">Boulder, CO 80301 · Single-family home</p>
</div>
<p className="text-sm text-muted-foreground">MLS #1048327</p>
</div>
<KeyFacts
facts={[
{ label: "List price", value: 1_485_000, icon: DollarSign, prefix: "$", compact: true, decimals: 3 },
{ label: "Bedrooms", value: 4, icon: BedDouble },
{ label: "Bathrooms", value: 3.5, icon: Bath, decimals: 1 },
{ label: "Interior", value: 3_264, icon: Ruler, suffix: " sqft" },
{ label: "Lot size", value: 0.42, icon: Trees, suffix: " ac", decimals: 2 },
{ label: "Year built", value: 2021, icon: Calendar, noGrouping: true },
]}
/>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/key-facts.jsonUsage
import { KeyFacts } from "@/components/facade/key-facts";
export default function Page() {
return <KeyFacts />;
}Props
KeyFactsProps is exported, so you can extend or wrap it. Every prop has a default; <KeyFacts /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| facts | KeyFact[] | |
| duration | number | Duration of the count-up in seconds. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade key facts to this page and adapt the copy to our business.