"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(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} {format(stat.value, places(stat.value))} {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 ( ); } /* 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(null); const grid = useRef(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 (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
{stats.map((stat, i) => { const delay = 0.1 + i * 0.1; return (
{stat.label}
{stat.note && (
{stat.note}
)} {stat.trend && stat.trend.length > 1 && (
)}
); })}
{children && ( {children} )}
); }