bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/stats-row

Usage

Usagetsx
<StatsRow />

Props

PropTypeDefaultDescription
eyebrowstringBy the numbersOptional eyebrow label above the heading.
headingstringBuilt to shipSection heading. Omit to render the stats row without a header block.
statsStat[][ { 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.
classNamestringExtra class names merged onto the outer <section>.

Source

stats-row.tsxtsx
"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