Stats
KPI cards with count-up numbers and animated bar sparklines.
Installation
npx shadcn@latest add @l8/statsUsage
import { StatsBlock } from '@/components/blocks/stats';
export default function Page() {
return <StatsBlock />;
}Source
"use client"
import * as React from "react"
import { animate, motion, useInView } from "motion/react"
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
const stats = [
{ label: "Revenue", value: 48250, prefix: "€", delta: 12.4, bars: [30, 45, 38, 60, 52, 70, 84] },
{ label: "Active users", value: 2840, delta: 8.1, bars: [40, 42, 55, 50, 62, 68, 75] },
{ label: "Churn", value: 2.3, suffix: "%", decimals: 1, delta: -0.4, lowerIsBetter: true, bars: [60, 55, 58, 48, 44, 40, 36] },
]
export function StatsBlock() {
return (
<section className="@container px-6 py-12">
<div className="mx-auto grid max-w-5xl gap-4 @xl:grid-cols-3">
{stats.map((s, i) => (
<motion.div
key={s.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
>
<Card className="rounded-xl">
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
<CountUp to={s.value} prefix={s.prefix} suffix={s.suffix} decimals={s.decimals} />
</CardTitle>
</CardHeader>
<CardContent className="flex items-end justify-between gap-4">
<Badge variant={s.delta > 0 !== !!s.lowerIsBetter ? "secondary" : "destructive"} className="gap-1">
{s.delta > 0 ? <TrendingUpIcon /> : <TrendingDownIcon />}
{s.delta > 0 ? "+" : ""}
{s.delta}%
</Badge>
<div className="flex h-12 items-end gap-1" aria-hidden>
{s.bars.map((h, j) => (
<motion.div
key={j}
className="w-2 rounded-full bg-primary/80"
initial={{ height: 0 }}
whileInView={{ height: `${h}%` }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 + j * 0.05, ease: "easeOut" }}
/>
))}
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</section>
)
}
function CountUp({
to,
prefix = "",
suffix = "",
decimals = 0,
}: {
to: number
prefix?: string
suffix?: string
decimals?: number
}) {
const ref = React.useRef<HTMLSpanElement>(null)
const inView = useInView(ref, { once: true })
const format = React.useCallback(
(v: number) =>
prefix + v.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + suffix,
[prefix, suffix, decimals]
)
React.useEffect(() => {
if (!inView) return
const controls = animate(0, to, {
duration: 1.4,
ease: [0.22, 1, 0.36, 1],
onUpdate: (v) => {
if (ref.current) ref.current.textContent = format(v)
},
})
return () => controls.stop()
}, [inView, to, format])
return <span ref={ref}>{format(0)}</span>
}