Numbers & Data Displayv0.1.0
Stat Card
A value, delta badge and mini chart that animate on change.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/stat-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --success, which shadcn does not define by default. Add it to your global CSS:
app/globals.css:root { --success: #15803d; } .dark { --success: #4ade80; } @theme inline { --color-success: var(--success); }Copy the source
components/ui/stat-card/stat-card.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type PointerEvent, type ReactNode } from "react"; import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface StatCardProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** What the number measures, in sentence case. */ label: string; /** Current value. Changes count from the old value to the new one. */ value: number; /** Value for the previous period; the delta is computed from it. */ previous?: number; /** Names the comparison period after the delta. @default "vs last period" */ comparison?: string; /** Recent values, oldest first (about 12). The current period is drawn in the accent. */ trend?: number[]; /** One label per trend point, shown on hover. */ trendLabels?: string[]; /** Which direction is good, for the delta color. @default "up" */ goodDirection?: "up" | "down"; /** Formats the value. @default full under 10,000, compact above (12.9K, 4.2M) */ formatValue?: (value: number) => string; /** Small icon beside the label. */ icon?: ReactNode; } const TREND_H = 44; const PAD = 6; const full = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); const autoFormat = (v: number) => (Math.abs(v) >= 10_000 ? compact.format(v) : full.format(v)); const f = (n: number) => Math.round(n * 100) / 100; /** Monotone cubic segments through the points: smooth, never overshooting. */ function segments(points: [number, number][]) { const n = points.length; if (n < 2) return []; const dx: number[] = []; const m: number[] = []; for (let i = 0; i < n - 1; i++) { dx.push(points[i + 1][0] - points[i][0]); m.push((points[i + 1][1] - points[i][1]) / dx[i]); } const t = [m[0]]; for (let i = 1; i < n - 1; i++) { t.push(m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i])); } t.push(m[n - 2]); return points.slice(0, -1).map(([x0, y0], i) => { const [x1, y1] = points[i + 1]; const h = dx[i] / 3; return `C${f(x0 + h)},${f(y0 + t[i] * h)},${f(x1 - h)},${f(y1 - t[i + 1] * h)},${f(x1)},${f(y1)}`; }); } export function StatCard({ label, value, previous, comparison = "vs last period", trend, trendLabels, goodDirection = "up", formatValue = autoFormat, icon, className, ...props }: StatCardProps) { const reduceMotion = useReducedMotion(); const trendRef = useRef<HTMLDivElement>(null); const inView = useInView(trendRef, { once: true, amount: 0.5 }); const [width, setWidth] = useState(0); const [hover, setHover] = useState<number | null>(null); // The value counts from the old number to the new one; the first render shows it as is. const shownValue = useMotionValue(value); const text = useTransform(shownValue, (v) => formatValue(v)); useEffect(() => { if (reduceMotion) { shownValue.set(value); return; } const controls = animate(shownValue, value, { duration: 0.8, ease: ease.out }); return () => controls.stop(); }, [value, reduceMotion, shownValue]); useEffect(() => { const el = trendRef.current; if (!el) return; const observer = new ResizeObserver(() => setWidth(el.clientWidth)); observer.observe(el); return () => observer.disconnect(); }, []); const change = previous === undefined || previous === 0 ? null : ((value - previous) / Math.abs(previous)) * 100; const direction = change === null || Math.abs(change) < 0.05 ? 0 : change > 0 ? 1 : -1; const good = direction === 0 ? null : (direction > 0) === (goodDirection === "up"); const changeText = change === null ? "" : `${direction > 0 ? "+" : direction < 0 ? "−" : ""}${Math.abs(change).toFixed(1)}%`; const DeltaIcon = direction > 0 ? ArrowUpRight : direction < 0 ? ArrowDownRight : Minus; const data = trend ?? []; const min = Math.min(...data); const span = Math.max(...data) - min || 1; const step = data.length > 1 ? (width - PAD * 2) / (data.length - 1) : 0; const points = data.map((v, i): [number, number] => [PAD + i * step, PAD + (1 - (v - min) / span) * (TREND_H - PAD * 2)]); const segs = segments(points); const line = points.length > 1 ? `M${f(points[0][0])},${f(points[0][1])}${segs.join("")}` : ""; const lastSegment = points.length > 1 ? `M${f(points[points.length - 2][0])},${f(points[points.length - 2][1])}${segs[segs.length - 1]}` : ""; const end = points[points.length - 1]; const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (data.length < 2) return; const x = event.clientX - event.currentTarget.getBoundingClientRect().left; setHover(Math.min(data.length - 1, Math.max(0, Math.round((x - PAD) / (step || 1))))); }; return ( <div className={cn("rounded-xl border border-border bg-card p-5 text-card-foreground shadow-xs", className)} {...props}> <div className="flex items-center justify-between gap-3"> <span className="text-sm text-muted-foreground">{label}</span> {icon ? <span className="text-muted-foreground [&_svg]:size-4">{icon}</span> : null} </div> <div className="mt-2 text-3xl font-semibold tracking-tight"> <span className="sr-only">{formatValue(value)}</span> <motion.span aria-hidden="true">{text}</motion.span> </div> {change !== null ? ( <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={changeText} initial={{ opacity: 0, y: reduceMotion ? 0 : 4, filter: reduceMotion ? "blur(0px)" : "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.base, ease: ease.out }} className={cn( "inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium tabular-nums", good === null ? "bg-muted text-foreground/65" : good ? "bg-success/10 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", )} > <DeltaIcon className="size-3.5" aria-hidden="true" /> <span className="sr-only">{direction > 0 ? "Up" : direction < 0 ? "Down" : "No change,"} </span> {changeText} </motion.span> </AnimatePresence> <span className="text-muted-foreground">{comparison}</span> </div> ) : null} {data.length > 1 ? ( <div ref={trendRef} className="relative mt-4" style={{ height: TREND_H }} onPointerMove={onPointerMove} onPointerLeave={() => setHover(null)} > <span className="sr-only"> Trend over {data.length} periods, low {formatValue(min)}, high {formatValue(min + span)} </span> {width > 0 ? ( <> <motion.svg width={width} height={TREND_H} className="absolute inset-0 overflow-visible" aria-hidden="true" initial={reduceMotion ? false : { clipPath: `inset(-6px ${width + 6}px -6px -6px)` }} animate={inView || reduceMotion ? { clipPath: "inset(-6px -8px -6px -6px)" } : undefined} transition={{ duration: 0.9, ease: ease.out }} > {/* Earlier periods in the de-emphasis gray; only the current period wears the accent. */} <motion.path initial={false} animate={{ d: line }} transition={{ duration: 0.5, ease: ease.out }} fill="none" stroke="color-mix(in oklab, var(--muted-foreground) 40%, var(--background))" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> <motion.path initial={false} animate={{ d: lastSegment }} transition={{ duration: 0.5, ease: ease.out }} fill="none" stroke="var(--chart-1)" strokeWidth={2} strokeLinecap="round" /> <motion.circle r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} initial={false} animate={{ cx: end[0], cy: end[1] }} transition={{ duration: 0.5, ease: ease.out }} /> {hover !== null ? ( <circle cx={points[hover][0]} cy={points[hover][1]} r={4} fill={hover === data.length - 1 ? "var(--chart-1)" : "var(--muted-foreground)"} stroke="var(--card)" strokeWidth={2} /> ) : null} </motion.svg> {hover !== null ? ( <div aria-hidden="true" className="pointer-events-none absolute bottom-full z-10 mb-1 -translate-x-1/2 rounded-md bg-foreground px-1.5 py-0.5 text-[11px] whitespace-nowrap text-background shadow-md" style={{ left: Math.min(Math.max(points[hover][0], 36), width - 36) }} > <span className="font-semibold">{formatValue(data[hover])}</span> {trendLabels?.[hover] ? <span className="ml-1 text-background/65">{trendLabels[hover]}</span> : null} </div> ) : null} </> ) : null} </div> ) : null} </div> ); }
Usage
import { StatCard } from "@/components/ui/stat-card/stat-card";
export function Revenue() {
return (
<StatCard
label="Revenue"
value={57400}
previous={52800}
comparison="vs last week"
trend={[38200, 39100, 41400, 40200, 43800, 45100, 44600, 47900, 49200, 48100, 51600, 57400]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | – | What the number measures, in sentence case. |
value* | number | – | Current value. Changes count from the old value to the new one. |
previous | number | – | Value for the previous period; the delta is computed from it. |
comparison | string | "vs last period" | Names the comparison period after the delta. |
trend | number[] | – | Recent values, oldest first (about 12). The current period is drawn in the accent. |
trendLabels | string[] | – | One label per trend point, shown on hover. |
goodDirection | "up" | "down" | "up" | Which direction is good, for the delta color. |
formatValue | (value: number) => string | full under 10,000, compact above (12.9K, 4.2M) | Formats the value. |
icon | ReactNode | – | Small icon beside the label. |
Also accepts every native <div> attribute, including ref and className.
- Follows the stat tile pattern: label, value (proportional figures, auto-compact above 10,000), signed delta against a named period, and an optional trend.
- The trend is drawn in a neutral gray with only the current period in the accent, so it supports the number instead of competing with it.
- Changing `value` counts from the old number; the trend morphs and the delta badge swaps with a short blur.
Accessibility
- The final value is read from a visually hidden copy; the counting digits are aria-hidden.
- The delta pairs color with an arrow and a signed percentage, and says “Up” or “Down” to screen readers; its color follows goodDirection.
- The trend has a text summary (period count, low and high). With reduced motion enabled, values change without counting and the trend appears without drawing on.