Browse the library
Blocks / Sections
Stats band
FreeBand of four big animated numbers with labels, trend chips and divider lines that draw in on view.
components/facade/stats-band.tsx
"use client";
import * as React from "react";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StatsBandItem {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
label: string;
/** Short trend text, e.g. "+18% vs 2024". */
trend?: string;
/** Direction of the trend. "down" can still be positive (e.g. response time). */
trendDirection?: "up" | "down";
/** Whether the trend is good news. Defaults to true. */
trendPositive?: boolean;
}
export interface StatsBandProps {
eyebrow?: string;
title?: string;
stats?: StatsBandItem[];
/** Renders the band on an inverted (primary) surface. */
inverted?: boolean;
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const DEFAULT_STATS: StatsBandItem[] = [
{ value: 12480, suffix: "+", label: "Jobs completed in Central Texas", trend: "+18% vs last year" },
{ value: 4.9, decimals: 1, suffix: "/5", label: "Average rating across 1,200 reviews", trend: "+0.2 this year" },
{ value: 47, suffix: " min", label: "Average arrival time for same-day calls", trend: "-9 min faster", trendDirection: "down" },
{ value: 98, suffix: "%", label: "Customers who would book us again", trend: "+3 pts" },
];
function AnimatedNumber({ item, delay }: { item: StatsBandItem; delay: number }) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px 0px" });
const reduce = useReducedMotion();
const decimals = item.decimals ?? 0;
const format = React.useCallback(
(n: number) =>
n.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}),
[decimals],
);
React.useEffect(() => {
const node = ref.current;
if (!node || !inView) return;
if (reduce) {
node.textContent = format(item.value);
return;
}
const c = animate(0, item.value, {
duration: 2.2,
delay,
ease: EASE,
onUpdate: (v) => {
node.textContent = format(v);
},
});
return () => c.stop();
}, [inView, reduce, item.value, delay, format]);
return (
<span className="tabular-nums">
{item.prefix}
<span ref={ref}>{format(0)}</span>
{item.suffix && <span className="text-[0.55em] font-medium opacity-60">{item.suffix}</span>}
</span>
);
}
export function StatsBand({
eyebrow = "By the numbers",
title = "Fifteen years of showing up on time.",
stats = DEFAULT_STATS,
inverted = false,
className,
}: StatsBandProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px 0px" });
const reduce = useReducedMotion();
return (
<section
className={cn(
"relative overflow-hidden py-20 sm:py-24",
inverted ? "bg-primary text-primary-foreground" : "bg-background text-foreground",
className,
)}
>
<div className="mx-auto max-w-7xl px-5 sm:px-8">
{(eyebrow || title) && (
<div className="mb-12 flex flex-col gap-3 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
{title && (
<h2 className="max-w-xl font-display text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{title}
</h2>
)}
{eyebrow && (
<p className="order-first text-xs font-semibold uppercase tracking-[0.2em] text-brand sm:order-none">
{eyebrow}
</p>
)}
</div>
)}
<div ref={ref} className="relative">
{/* Top rule drawing in */}
<motion.div
aria-hidden
className={cn("absolute inset-x-0 top-0 h-px origin-left", inverted ? "bg-primary-foreground/20" : "bg-border")}
initial={{ scaleX: reduce ? 1 : 0 }}
animate={inView ? { scaleX: 1 } : undefined}
transition={{ duration: 1.2, ease: EASE }}
/>
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s, i) => {
const positive = s.trendPositive ?? true;
return (
<li key={s.label} className="relative px-0 py-8 sm:px-6 lg:py-10 lg:first:pl-0">
{/* Vertical divider drawing in (desktop) */}
{i > 0 && (
<motion.span
aria-hidden
className={cn(
"absolute left-0 top-8 bottom-8 hidden w-px origin-top lg:block",
inverted ? "bg-primary-foreground/20" : "bg-border",
)}
initial={{ scaleY: reduce ? 1 : 0 }}
animate={inView ? { scaleY: 1 } : undefined}
transition={{ duration: 0.9, delay: 0.3 + i * 0.12, ease: EASE }}
/>
)}
{/* Horizontal divider on small screens */}
{i > 0 && (
<motion.span
aria-hidden
className={cn(
"absolute inset-x-0 top-0 h-px origin-left lg:hidden",
i === 1 && "sm:hidden",
inverted ? "bg-primary-foreground/20" : "bg-border",
)}
initial={{ scaleX: reduce ? 1 : 0 }}
animate={inView ? { scaleX: 1 } : undefined}
transition={{ duration: 0.9, delay: 0.2 + i * 0.1, ease: EASE }}
/>
)}
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.8, delay: 0.15 + i * 0.1, ease: EASE }}
className="flex flex-col-reverse"
>
<p
className={cn(
"mt-3 max-w-[16rem] text-sm leading-relaxed",
inverted ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
{s.label}
</p>
<p className="font-display text-5xl font-semibold tracking-tight sm:text-6xl">
<AnimatedNumber item={s} delay={0.2 + i * 0.1} />
</p>
</motion.div>
{s.trend && (
<motion.p
initial={{ opacity: 0 }}
animate={inView ? { opacity: 1 } : undefined}
transition={{ duration: 0.6, delay: 1.4 + i * 0.1 }}
className={cn(
"mt-4 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
positive ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
)}
>
<TrendingUp
className={cn("size-3", s.trendDirection === "down" && "-scale-y-100")}
aria-hidden
/>
{s.trend}
</motion.p>
)}
</li>
);
})}
</ul>
<motion.div
aria-hidden
className={cn("absolute inset-x-0 bottom-0 h-px origin-right", inverted ? "bg-primary-foreground/20" : "bg-border")}
initial={{ scaleX: reduce ? 1 : 0 }}
animate={inView ? { scaleX: 1 } : undefined}
transition={{ duration: 1.2, delay: 0.2, ease: EASE }}
/>
</div>
</div>
</section>
);
}
export default StatsBand;app/page.tsx
import { StatsBand } from "@/components/facade/stats-band";
export default function Demo() {
return <StatsBand />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/stats-band.jsonUsage
import { StatsBand } from "@/components/facade/stats-band";
export default function Page() {
return <StatsBand />;
}Props
StatsBandProps is exported, so you can extend or wrap it. Every prop has a default; <StatsBand /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| eyebrow | string | |
| title | string | |
| stats | StatsBandItem[] | |
| inverted | boolean | Renders the band on an inverted (primary) surface. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade stats band to this page and adapt the copy to our business.