# Stat Card

> KPI card with a sparkline and a trend badge.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/stat-card
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/stat-card.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/stat-card-demo.tsx"
import { StatCard } from "@/components/stat-card"

export default function StatCardDemo() {
  return (
    <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
      <StatCard
        label="Monthly revenue"
        value="$45,231"
        change={20.1}
        caption="vs. last month"
        data={[12, 18, 15, 22, 19, 28, 26, 34, 31, 40]}
      />
      <StatCard
        label="Churn rate"
        value="2.4%"
        change={-0.8}
        caption="vs. last month"
        invertTrend
        data={[9, 8, 8.5, 7, 7.4, 6, 6.2, 5, 4.6, 4]}
      />
    </div>
  )
}
```

## Source

```tsx title="components/stat-card.tsx"
import * as React from "react"
import { TrendingDown, TrendingUp } from "lucide-react"

import { cn } from "@/lib/utils"

export interface StatCardProps extends React.ComponentProps<"div"> {
  label: string
  value: string
  /** Percentage change vs. the previous period, e.g. 12.5 or -3.2 */
  change: number
  caption?: string
  /** Sparkline data points, oldest first */
  data: number[]
  /** When true, a negative change is shown as good (e.g. churn, latency) */
  invertTrend?: boolean
}

function sparklinePath(data: number[], width: number, height: number) {
  const min = Math.min(...data)
  const max = Math.max(...data)
  const span = max - min || 1
  const step = width / Math.max(data.length - 1, 1)
  return data
    .map((d, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - ((d - min) / span) * height).toFixed(1)}`)
    .join(" ")
}

export function StatCard({
  label,
  value,
  change,
  caption,
  data,
  invertTrend = false,
  className,
  ...props
}: StatCardProps) {
  const id = React.useId().replace(/[^\w-]/g, "")
  const up = change >= 0
  const good = invertTrend ? !up : up
  const Trend = up ? TrendingUp : TrendingDown
  const line = sparklinePath(data, 120, 40)

  return (
    <div
      className={cn("w-full max-w-xs rounded-2xl border bg-card p-5 text-card-foreground shadow-sm", className)}
      {...props}
    >
      <div className="flex items-center justify-between">
        <p className="text-sm text-muted-foreground">{label}</p>
        <span
          className={cn(
            "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
            good
              ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
              : "bg-rose-500/10 text-rose-600 dark:text-rose-400"
          )}
        >
          <Trend className="size-3" />
          {up ? "+" : ""}
          {change.toFixed(1)}%
        </span>
      </div>
      <div className="mt-3 flex items-end justify-between gap-4">
        <div>
          <p className="text-3xl font-semibold tracking-tight tabular-nums">{value}</p>
          {caption && <p className="mt-1 text-xs text-muted-foreground">{caption}</p>}
        </div>
        <svg
          viewBox="0 -2 120 44"
          className={cn("h-11 w-28 shrink-0", good ? "text-emerald-500" : "text-rose-500")}
          aria-hidden
        >
          <defs>
            <linearGradient id={id} x1="0" x2="0" y1="0" y2="1">
              <stop offset="0%" stopColor="currentColor" stopOpacity={0.25} />
              <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
            </linearGradient>
          </defs>
          <path d={`${line} L120,40 L0,40 Z`} fill={`url(#${id})`} />
          <path d={line} fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </div>
    </div>
  )
}
```
