sections
Stats Row
A metrics band section: an optional eyebrow and heading stagger-reveal on scroll (Motion useInView), then a responsive row of 3–4 stat cells each containing a large display number that counts up from 0 to its target when the grid enters the viewport. Count-up uses a rAF easeOutQuad loop over a 1.5 s budget, staggered 100 ms per cell left-to-right. Each stat accepts an optional prefix (e.g. '$') and suffix (e.g. '%', '+'), a label, and an optional one-line caption. Integers are locale-grouped (e.g. 12,400). Token-driven hairline dividers separate cells on desktop; the layout collapses to a 2-col grid on mobile. Under prefers-reduced-motion or before mount, all cells show their final values immediately — never stuck at 0.
By the numbers
Built to ship
- 62+ Components. Across 7 categories
- 6 Categories. Sections, effects, three.js & more
- 100% Open / MIT. Free to fork, own, and extend
- 1 Install command. npx shadcn add @bottega/*
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/stats-rowUsage
<StatsRow />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | By the numbers | Optional eyebrow label above the heading. |
| heading | string | Built to ship | Section heading. Omit to render the stats row without a header block. |
| stats | Stat[] | [ { value: 62, suffix: "+", label: "Components", caption: "Across 7 categories", }, { value: 6, label: "Categories", caption: "Sections, effects, three.js & more", }, { value: 100, suffix: "%", label: "Open / MIT", caption: "Free to fork, own, and extend", }, { value: 1, label: "Install command", caption: "npx shadcn add @bottega/*", }, ] | 3–4 stats. Defaults to four Bottega showcase metrics. |
| className | string | — | Extra class names merged onto the outer <section>. |
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
// Count-up animation budget in ms. Distinct from the --duration CSS UI-transition token (~300ms).
const COUNT_DURATION_MS = 1500;
// Scene reveal easing — exponential-out: snappy arrival, long decelerating tail.
// In SECONDS (Motion JS API), not the --ease CSS token.
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;
/** EaseOutQuad: quick burst, smooth deceleration into the target value. */
function easeOutQuad(t: number): number {
return 1 - (1 - t) * (1 - t);
}
/** Format with fixed-locale grouping: 12400 → "12,400". Fixed locale ("en-US")
* so SSR and client produce identical strings (avoids React #418 ≥ 1000). */
function fmtNumber(n: number): string {
return Math.round(n).toLocaleString("en-US");
}
function cx(...parts: (string | false | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
export interface Stat {
/** Integer target to count up to on scroll-into-view. */
value: number;
/** Static prefix before the number, e.g. "$", "+". */
prefix?: string;
/** Static suffix after the number, e.g. "%", "k", "+". */
suffix?: string;
/** Short label below the number, e.g. "Components". */
label: string;
/** Optional one-line caption below the label. */
caption?: string;
}
export interface StatsRowProps {
/** Optional eyebrow label above the heading. */
eyebrow?: string;
/** Section heading. Omit to render the stats row without a header block. */
heading?: string;
/** 3–4 stats. Defaults to four Bottega showcase metrics. */
stats?: Stat[];
/** Extra class names merged onto the outer <section>. */
className?: string;
}
const DEFAULT_STATS: Stat[] = [
{
value: 62,
suffix: "+",
label: "Components",
caption: "Across 7 categories",
},
{
value: 6,
label: "Categories",
caption: "Sections, effects, three.js & more",
},
{
value: 100,
suffix: "%",
label: "Open / MIT",
caption: "Free to fork, own, and extend",
},
{
value: 1,
label: "Install command",
caption: "npx shadcn add @bottega/*",
},
];
export function StatsRow({
eyebrow = "By the numbers",
heading = "Built to ship",
stats = DEFAULT_STATS,
className,
}: StatsRowProps) {
const sectionRef = useRef<HTMLElement>(null);
const gridRef = useRef<HTMLUListElement>(null);
// Mount-gate + null-safe reduced-motion check (Bottega contract pattern).
// SSR + first client render: mounted=false → animate=false → static final values visible.
// After hydration: animate flips true for non-reduced users; count-up starts on scroll.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null ⇒ reduced
const animate = mounted && !reduce;
// Separate in-view triggers: header reveals on section entry, cells count-up on grid entry.
const headerInView = useInView(sectionRef, { once: true, amount: 0.3 });
const gridInView = useInView(gridRef, { once: true, amount: 0.15 });
const showHeader = animate && headerInView;
return (
<section
ref={sectionRef}
className={cx(
"w-full bg-background text-foreground",
"px-6 py-16 sm:py-20",
className,
)}
>
<div className="mx-auto max-w-5xl">
{/* Optional header block: eyebrow + heading stagger-reveal on scroll. */}
{(eyebrow || heading) && (
<div className="mb-14 text-center">
{eyebrow && (
<motion.p
initial={animate ? { opacity: 0, y: 10 } : false}
animate={
showHeader
? { opacity: 1, y: 0 }
: animate
? { opacity: 0, y: 10 }
: false
}
transition={{ duration: 0.5, ease: EASE_EXPO_OUT }}
className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-foreground opacity-50"
>
{eyebrow}
</motion.p>
)}
{heading && (
<motion.h2
initial={animate ? { opacity: 0, y: 14 } : false}
animate={
showHeader
? { opacity: 1, y: 0 }
: animate
? { opacity: 0, y: 14 }
: false
}
transition={{
duration: 0.55,
ease: EASE_EXPO_OUT,
// Heading follows eyebrow with a brief lead-in gap.
delay: showHeader ? (eyebrow ? 0.07 : 0) : 0,
}}
className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"
>
{heading}
</motion.h2>
)}
</div>
)}
{/* Stats grid: 2-col on mobile, 4-col on desktop.
Token-driven hairline dividers between cells at md+ breakpoint. */}
<ul
ref={gridRef}
className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4 md:gap-x-0 md:gap-y-0"
aria-label="Key metrics"
>
{stats.map((stat, i) => (
<StatCell
key={i}
stat={stat}
index={i}
animate={animate}
inView={animate && gridInView}
/>
))}
</ul>
</div>
</section>
);
}
function StatCell({
stat,
index,
animate,
inView,
}: {
stat: Stat;
index: number;
animate: boolean;
inView: boolean; // pre-combined: animate && gridInView
}) {
const { value, prefix, suffix, label, caption } = stat;
// Count-up: each cell staggers 100ms later than the one before it.
// Under reduced-motion / pre-mount (inView=false when !animate), shows target immediately.
const displayValue = useCountUp(value, inView, index * 100);
// Motion reveal stagger in seconds (distinct from the --duration CSS token).
const motionDelay = index * 0.09;
return (
<motion.li
initial={animate ? { opacity: 0, y: 18 } : false}
animate={
inView
? { opacity: 1, y: 0 }
: animate
? { opacity: 0, y: 18 }
: false
}
transition={{
duration: 0.5,
ease: EASE_EXPO_OUT,
delay: inView ? motionDelay : 0,
}}
className={cx(
"flex flex-col items-center px-4 py-2 text-center",
// ponytail: border-l on every cell after the first; md: prefix keeps it desktop-only
index > 0 && "md:border-l md:border-border",
)}
>
{/* Screen-reader text: static final value — AT hears this, not the animated counter. */}
<span className="sr-only">
{`${prefix ?? ""}${Math.round(value).toLocaleString("en-US")}${suffix ?? ""} ${label}${
caption ? `. ${caption}` : ""
}`}
</span>
{/* Visual number display: aria-hidden so AT uses the sr-only above. */}
<div className="flex items-baseline gap-0.5" aria-hidden="true">
{prefix && (
<span className="text-xl font-medium tabular-nums text-foreground opacity-60 sm:text-2xl">
{prefix}
</span>
)}
<span className="text-[3.25rem] font-bold leading-none tracking-tight tabular-nums text-foreground sm:text-[4rem]">
{fmtNumber(displayValue)}
</span>
{suffix && (
<span className="text-xl font-medium tabular-nums text-foreground opacity-60 sm:text-2xl">
{suffix}
</span>
)}
</div>
<p
className="mt-3 text-sm font-semibold text-foreground"
aria-hidden="true"
>
{label}
</p>
{caption && (
<p
className="mt-1 max-w-[9rem] text-xs leading-snug text-foreground opacity-50"
aria-hidden="true"
>
{caption}
</p>
)}
</motion.li>
);
}
/**
* Count-up hook: rAF loop from 0 → target over COUNT_DURATION_MS using easeOutQuad.
*
* Initializes to `target` so SSR HTML === first client render (avoids React #418).
* When not enabled (reduced-motion or pre-mount), snaps to target immediately —
* never leaves reduced-motion users stuck at 0.
* When enabled (animate && inView), briefly resets to 0 then counts up.
*/
function useCountUp(target: number, enabled: boolean, delayMs: number): number {
// Start at target: SSR + first render + reduced-motion all show the final value.
const [current, setCurrent] = useState(target);
const rafRef = useRef<number>(0);
useEffect(() => {
cancelAnimationFrame(rafRef.current);
if (!enabled) {
// Reduced-motion / pre-mount: show final value with no animation.
setCurrent(target);
return;
}
// Kick off count-up after the stagger delay.
// setCurrent(0) makes the number flash from target→0 for <1 frame before
// the first rAF tick fires — acceptable, and below-fold elements don't show it.
setCurrent(0);
let startTime: number | null = null;
const timeoutId = setTimeout(() => {
function tick(now: number) {
if (startTime === null) startTime = now;
const elapsed = now - startTime;
const progress = Math.min(elapsed / COUNT_DURATION_MS, 1);
const next = easeOutQuad(progress) * target;
// Snap to exact integer target on finish to avoid floating-point drift.
setCurrent(progress < 1 ? next : target);
if (progress < 1) {
rafRef.current = requestAnimationFrame(tick);
}
}
rafRef.current = requestAnimationFrame(tick);
}, delayMs);
return () => {
clearTimeout(timeoutId);
cancelAnimationFrame(rafRef.current);
};
}, [enabled, target, delayMs]);
return current;
}
Dependencies
- motion
- @bottega/tokens