Skip to content
All Taste components

Animated charts

Count-up KPI cards

Headline numbers that roll up to their value digit by digit, each with a sparkline drawing underneath.

4 cards · rolling digits · sparkline draw · deltas · wraps to 2 or 1 columns

Monthly revenue₹4,82,100+12.4%
Active customers3,214+6.2%
Churn1.8%−0.4 pt
Average order₹1,496+3.1%
"use client";
import { useEffect, useState } from "react";

/** Counts from 0 to the target with an ease-out; jumps straight there for reduced motion. */
function useCountUp(target: number, duration = 1400, delay = 0) {
  const [value, setValue] = useState(0);
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setValue(target);
      return;
    }
    let frame = 0;
    const startAt = performance.now() + delay;
    const tick = (now: number) => {
      const t = Math.min(Math.max((now - startAt) / duration, 0), 1);
      setValue(target * (1 - Math.pow(1 - t, 3)));
      if (t < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [target, duration, delay]);
  return value;
}

const kpis = [
  { label: "Monthly revenue", value: 482100, format: (n: number) => "₹" + Math.round(n).toLocaleString("en-IN"), delta: "+12.4%", spark: "M0,22 C10,20 16,24 26,18 S42,16 52,12 S70,14 80,8 S94,6 100,3" },
  { label: "Active customers", value: 3214, format: (n: number) => Math.round(n).toLocaleString("en-IN"), delta: "+6.2%", spark: "M0,20 C12,21 18,16 28,17 S44,12 54,13 S72,9 82,10 S95,6 100,5" },
  { label: "Churn", value: 1.8, format: (n: number) => n.toFixed(1) + "%", delta: "−0.4 pt", spark: "M0,6 C10,7 18,9 28,8 S44,13 54,14 S70,16 80,19 S94,20 100,22" },
  { label: "Average order", value: 1496, format: (n: number) => "₹" + Math.round(n).toLocaleString("en-IN"), delta: "+3.1%", spark: "M0,18 C10,14 18,19 28,15 S44,17 54,12 S70,14 80,10 S94,11 100,8" },
];

function Kpi({ k, index }: { k: (typeof kpis)[number]; index: number }) {
  const n = useCountUp(k.value, 1400, index * 120);
  return (
    <article className="grid content-start gap-1.5 rounded-lg border border-border bg-bg p-4 animate-rise motion-reduce:animate-none" style={{ animationDelay: index * 70 + "ms" }}>
      <p className="text-xs font-medium text-fg-subtle">{k.label}</p>
      {/* Screen readers get the final value, not every frame */}
      <p className="text-[26px] font-medium leading-tight tracking-tight tabular-nums text-fg">
        <span aria-hidden>{k.format(n)}</span>
        <span className="sr-only">{k.format(k.value)}</span>
      </p>
      <span className="justify-self-start rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-fg">{k.delta}</span>
      <svg viewBox="0 0 100 28" className="mt-1.5 block h-auto w-full" aria-hidden>
        <path d={k.spark} pathLength={1} className="fill-none stroke-primary [stroke-dasharray:1] [stroke-linecap:round] [stroke-width:1.75] animate-draw motion-reduce:animate-none" style={{ animationDelay: 300 + index * 120 + "ms" }} />
      </svg>
    </article>
  );
}

export function KpiCountUp() {
  return (
    <section aria-label="Key numbers" className="grid grid-cols-[repeat(auto-fit,minmax(148px,1fr))] gap-3 font-sans">
      {kpis.map((k, i) => <Kpi key={k.label} k={k} index={i} />)}
    </section>
  );
}

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