l8components

Stats

KPI cards with count-up numbers and animated bar sparklines.

Revenue
€0
+12.4%
Active users
0
+8.1%
Churn
0.0%
-0.4%

Installation

npx shadcn@latest add @l8/stats

Usage

import { StatsBlock } from '@/components/blocks/stats';

export default function Page() {
  return <StatsBlock />;
}

Source

components/blocks/stats.tsx
"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>
}

On this page