shadcn KPI card: the free component
components/kpi-card.tsx
"use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { useCountUp } from "@/hooks/use-count-up";
type KpiCardProps = {
label: string;
value: number;
format?: Intl.NumberFormatOptions;
/** % change against the last period */
delta?: number;
/** for numbers where down is good, like churn or latency */
lowerIsBetter?: boolean;
hint?: string;
};
export function KpiCard({ label, value, format, delta, lowerIsBetter = false, hint }: KpiCardProps) {
const shown = useCountUp(value);
const nf = new Intl.NumberFormat("en-US", format);
const good = delta === undefined ? true : lowerIsBetter ? delta <= 0 : delta >= 0;
return (
<Card className="@container/card">
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{/* screen readers get the final number, not every frame of the count */}
<span aria-hidden="true">{nf.format(shown)}</span>
<span className="sr-only">{nf.format(value)}</span>
</CardTitle>
{delta !== undefined && (
<CardAction>
<Badge variant="outline" className={good ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
{delta > 0 ? "+" : ""}
{delta.toFixed(1)}%
</Badge>
</CardAction>
)}
</CardHeader>
{hint && <CardFooter className="text-sm text-muted-foreground">{hint}</CardFooter>}
</Card>
);
}
hooks/use-count-up.ts
"use client";
import { useEffect, useRef, useState } from "react";
// Starts at the value the server rendered, then eases to each new value.
export function useCountUp(target: number, durationMs = 800): number {
const [value, setValue] = useState(target);
const fromRef = useRef(target);
useEffect(() => {
const from = fromRef.current;
if (from === target) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
fromRef.current = target;
setValue(target);
return;
}
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / durationMs, 1);
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
const next = from + (target - from) * eased;
fromRef.current = next;
setValue(next);
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, durationMs]);
return value;
}