Skip to content
All Taste components

Text effects

Number ticker

Stats that count up from zero and settle on the real number, which is also what shows at rest.

3 stats · suffixes · staggered

98%Say it saves time
64Countries
12kTeams shipping
"use client";

import { useEffect, useRef, useState } from "react";

interface NumberTickerProps {
  value: number;
  suffix?: string;
  /** Milliseconds. */
  duration?: number;
}

/** Renders the real value first (server, no JS, reduced motion), counts up once it scrolls into view. */
export function NumberTicker({ value, suffix = "", duration = 1800 }: NumberTickerProps) {
  const [shown, setShown] = useState(value);
  const ref = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let raf = 0;
    const io = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) return;
      io.disconnect();
      const start = performance.now();
      const tick = (now: number) => {
        const p = Math.min(1, (now - start) / duration);
        setShown(Math.round(value * (1 - Math.pow(1 - p, 4))));
        if (p < 1) raf = requestAnimationFrame(tick);
      };
      raf = requestAnimationFrame(tick);
    });
    io.observe(el);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [value, duration]);

  return (
    <span ref={ref} aria-label={value.toLocaleString() + suffix} className="tabular-nums">
      <span aria-hidden>{shown.toLocaleString()}</span>
      {suffix && <span aria-hidden className="text-primary">{suffix}</span>}
    </span>
  );
}

export function Stats() {
  const stats = [
    { value: 98, suffix: "%", label: "Say it saves time" },
    { value: 64, suffix: "", label: "Countries" },
    { value: 12, suffix: "k", label: "Teams shipping" },
  ];
  return (
    <dl className="grid max-w-md grid-cols-3 divide-x divide-border">
      {stats.map((s) => (
        <div key={s.label} className="grid gap-0.5 px-3 first:pl-0">
          <dd className="text-[34px] font-medium leading-tight tracking-tight text-fg">
            <NumberTicker value={s.value} suffix={s.suffix} />
          </dd>
          <dt className="order-last text-[13px] text-fg-muted">{s.label}</dt>
        </div>
      ))}
    </dl>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.