Website Sections
StatsShowcase
Big proof numbers in a lit grid. Numbers count up as the section scrolls in, small charts draw themselves, and the grid lines glow near the pointer.
Download
- React
- Tailwind CSS v4
- motion
components/ui/stats-showcase.tsx
"use client";
import { useEffect, useId, useRef, type ElementType, type PointerEvent, type ReactNode } from "react";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
export type StatsShowcaseStat = {
label: string;
value: number;
/** Goes before the number, such as $. */
prefix?: string;
/** Goes after the number, such as %. */
suffix?: string;
/** Small line under the number. */
note?: string;
/** Small chart under the number, oldest first. Two values or more. */
trend?: number[];
};
type StatsShowcaseProps = {
stats: StatsShowcaseStat[];
title: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Goes under the numbers: a link or a button. */
children?: ReactNode;
/** Colour of the charts, the notes and the light on the lines. */
color?: string;
/** Tile colour. */
tile?: string;
/** Size of the light that follows the pointer, in px. */
reach?: number;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
const places = (value: number) => (String(value).split(".")[1] ?? "").length;
const format = (value: number, digits: number) => value.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
function Count({ stat, start, delay }: { stat: StatsShowcaseStat; start: boolean; delay: number }) {
const number = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = number.current;
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const digits = places(stat.value);
el.textContent = format(0, digits);
if (!start) return;
const run = animate(0, stat.value, {
duration: 1.6,
delay,
ease: EASE,
onUpdate: (now) => {
el.textContent = format(now, digits);
},
});
return () => run.stop();
}, [stat.value, start, delay]);
return (
<>
{stat.prefix}
<span ref={number}>{format(stat.value, places(stat.value))}</span>
{stat.suffix}
</>
);
}
function Trend({ stat, color, start, delay, instant }: { stat: StatsShowcaseStat; color: string; start: boolean; delay: number; instant: boolean }) {
const trend = stat.trend ?? [];
const low = Math.min(...trend);
const span = Math.max(...trend) - low || 1;
const line = trend.map((value, i) => `${i ? "L" : "M"}${((i / (trend.length - 1)) * 100).toFixed(2)} ${(22 - ((value - low) / span) * 20).toFixed(2)}`).join(" ");
return (
<motion.div
className="mt-4 h-8"
initial={{ clipPath: "inset(-10% 100% -10% 0%)" }}
animate={{ clipPath: start ? "inset(-10% 0% -10% 0%)" : "inset(-10% 100% -10% 0%)" }}
transition={{ duration: instant ? 0 : 1.4, delay: instant ? 0 : delay + 0.2, ease: "easeInOut" }}
>
<svg role="img" aria-label={`${stat.label} trend: from ${trend[0]} to ${trend[trend.length - 1]}`} viewBox="0 0 100 24" preserveAspectRatio="none" className="size-full overflow-visible">
<path d={line} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
</motion.div>
);
}
/* StatsShowcase: big proof numbers in a lit grid. Numbers count up when the section scrolls in, small charts draw themselves, and the grid lines glow near the pointer. */
export function StatsShowcase({
stats,
title,
eyebrow,
subtitle,
children,
color = "#a78bfa",
tile = "#15122a",
reach = 280,
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: StatsShowcaseProps) {
const reduce = useReducedMotion();
const id = useId();
const section = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const inView = useInView(section, { once: true, amount: 0.15 });
const instant = Boolean(reduce);
const move = (e: PointerEvent) => {
const box = grid.current?.getBoundingClientRect();
if (e.pointerType !== "mouse" || !box) return;
grid.current?.style.setProperty("--ss-x", `${e.clientX - box.left}px`);
grid.current?.style.setProperty("--ss-y", `${e.clientY - box.top}px`);
};
const rise = (delay: number) => ({
initial: instant ? { opacity: 0 } : { opacity: 0, y: 20 },
animate: inView ? { opacity: 1, y: 0 } : undefined,
transition: { duration: instant ? 0.3 : 0.7, delay: instant ? 0 : delay, ease: EASE },
});
return (
<section ref={section} aria-labelledby={id} className={`@container ${className}`}>
<div className="mx-auto max-w-6xl">
<motion.div {...rise(0)} className="max-w-2xl">
{eyebrow && <p className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:text-sm">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.6rem,7cqw,2.75rem)] leading-[1.08] font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-sm text-pretty opacity-70 @2xl:text-lg">{subtitle}</p>}
</motion.div>
<div ref={grid} onPointerMove={move} className="group relative mt-8 overflow-hidden rounded-2xl bg-current/10 @2xl:mt-12">
<span
aria-hidden
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-fine:group-hover:opacity-100"
style={{ backgroundImage: `radial-gradient(${reach}px at var(--ss-x, 50%) var(--ss-y, 50%), ${color}, transparent)` }}
/>
<dl className="relative flex flex-wrap gap-px p-px">
{stats.map((stat, i) => {
const delay = 0.1 + i * 0.1;
return (
<div key={stat.label} className="min-w-0 flex-1 basis-52 p-5 @2xl:p-7" style={{ backgroundColor: `color-mix(in srgb, ${tile} 94%, transparent)` }}>
<motion.div {...rise(delay)}>
<dt className="text-sm opacity-70">{stat.label}</dt>
<dd className="mt-2 text-[clamp(2.25rem,7cqw,3.5rem)] leading-none font-semibold tracking-tight tabular-nums">
<Count stat={stat} start={inView} delay={delay} />
</dd>
{stat.note && (
<dd className="mt-2 text-sm" style={{ color }}>
{stat.note}
</dd>
)}
{stat.trend && stat.trend.length > 1 && (
<dd>
<Trend stat={stat} color={color} start={inView} delay={delay} instant={instant} />
</dd>
)}
</motion.div>
</div>
);
})}
</dl>
</div>
{children && (
<motion.div {...rise(0.2 + stats.length * 0.1)} className="mt-8 flex flex-wrap gap-3">
{children}
</motion.div>
)}
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/stats-showcase.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/stats-showcase-demo.tsx
import { StatsShowcase } from "@/components/ui/stats-showcase"; const STATS = [ { label: "Revenue", value: 48250, prefix: "$", note: "+12% this month", trend: [32, 38, 35, 46, 44, 58, 54, 67, 72, 84, 96] }, { label: "Active users", value: 3912, note: "+284 this week", trend: [20, 24, 23, 31, 36, 35, 44, 52, 58] }, { label: "Uptime", value: 99.98, suffix: "%", note: "Last 30 days" }, { label: "Reply time", value: 1.4, suffix: "h", note: "Down from 3.1h", trend: [31, 28, 29, 24, 20, 19, 16, 14] }, ]; export default function StatsShowcaseExample() { return ( <div className="h-full overflow-y-auto"> <StatsShowcase headingAs="h3" eyebrow="Sample numbers" title="Proof, in four numbers" subtitle="Move the pointer over the grid to light the lines." stats={STATS} className="min-h-full bg-[#0b0a14] px-5 py-10 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| stats | StatsShowcaseStat[] | โ | Each has label and value, plus optional prefix, suffix, note and trend. trend is a small chart, oldest first. |
| title | string | โ | Heading above the grid. |
| eyebrow | string | โ | Small line above the title. |
| subtitle | string | โ | Line under the title. |
| children | ReactNode | โ | Goes under the numbers: a link or a button. |
| color | string | "#a78bfa" | Colour of the charts, the notes and the light on the lines. |
| tile | string | "#15122a" | Tile colour. |
| reach | number | 280 | Size of the light that follows the pointer, in px. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |