Display

Stat Card

A KPI grid — label, big tabular figure and a tone-coded delta per metric — with loading, empty, error and ready driven by one contract.

Preview in your theme

Loading preview…

"use client"

import { ArrowDown, ArrowUp, ChartColumn, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
import type { StatCardData, StatCardItem } from "./stat-card.contract"

export interface StatCardProps extends StatCardData {
  /** Metric columns at the widest breakpoint; always collapses to 1 on narrow screens. */
  columns?: 1 | 2 | 3 | 4
  /** Wire your refetch here — the error state only renders the button when it exists. */
  onRetry?: () => void
  className?: string
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stat-card.json

Prompt

Build a React + TypeScript + Tailwind "StatCard" component (lucide-react
ArrowUp / ArrowDown / Minus / ChartColumn) with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    items: { id, label, value (number | string), unit?, delta? (percent),
    trend: "up" | "down" | "flat", hint? }[] }.
- Component props = z.infer of the schema plus presentation-only extras:
  columns?: 1 | 2 | 3 | 4 (default 4), onRetry?: () => void, className.
- value accepts a raw number (the component formats it with
  Intl.NumberFormat) or an already-formatted string like "4m 12s" / "1.2M",
  so hosts that pre-format server-side don't have to fight the formatter.
- trend is separate from delta's sign: trend picks the icon and the tone,
  delta is only the printed magnitude. Metrics where "down is good" flip one
  field, not the data.

Behavior
- Four first-class branches, never an afterthought &&:
  loading renders `columns` skeleton cards using the exact same card box and
  the same three rows (label bar / figure bar / delta bar) as ready, so the
  row's height never jumps when data lands; the grid carries aria-busy and the
  skeleton innards are aria-hidden.
  empty renders one full-width dashed panel: a muted chart glyph plus a single
  guiding sentence — no fake zeros.
  error renders a destructive headline, a muted explanation and a "Try again"
  button that exists only when onRetry was passed; the component never fetches
  or retries on its own.
  ready renders the metric grid.
- Each metric card: label on top, the figure at text-2xl semibold with an
  optional unit on the same baseline, then a delta chip and an optional hint
  on one wrapping row. Cards with neither delta nor hint simply drop that row.
- Delta chip = arrow icon + signed percentage to one decimal. The icon is
  aria-hidden and an sr-only word ("up" / "down" / "flat") is read before the
  number, so the direction survives without color.
- Every figure and percentage uses tabular-nums so numbers in adjacent cards
  line up and a live-updating value doesn't wobble.
- Responsive: the grid collapses to one column on narrow screens regardless of
  `columns`, so cards never squeeze below readable width.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground + border rounded-xl for
  the card, text-muted-foreground for labels, hints and the empty state,
  bg-muted for skeleton bars, text-destructive for the error headline and the
  falling delta, text-chart-2 for the rising delta, text-muted-foreground for
  flat. No hardcoded colors, so dark mode and any host palette come free.
- Column classes come from a small static lookup keyed by `columns` — never a
  template-string class, which Tailwind cannot see at build time.
- cn() merges the consumer className onto whichever branch is rendering, so a
  host can widen gaps or cap the width from the outside.
- The retry button carries focus-visible:ring-2 focus-visible:ring-ring and a
  hover background; it is the only interactive element in the component.

Customization levers
- Density: card padding (p-4), inner gap (gap-1.5) and figure size (text-2xl)
  are one token each — shrink for a 6-up admin strip, grow for a 2-up hero.
- Columns: the lookup table is the whole responsive story; add a 6-key or
  change the breakpoints there without touching the render.
- Tone mapping: one TREND table maps up/down/flat to icon + text token — invert
  it for "lower is better" metrics (churn, latency, cost), or move both tones
  onto chart tokens when primary is reserved for CTAs.
- Delta presentation: swap the signed percent for absolute deltas, or wrap the
  chip in a tinted pill via color-mix(in oklab, var(--chart-2) 12%, transparent).
- Sub-blocks: unit, delta and hint are all optional in the contract — drop them
  for a bare number wall, or extend the item with an icon/href field and render
  it in the label row.
- Skeleton count: loading mirrors `columns`; feed it the last known item count
  instead if you cache the previous payload.

Concepts

  • Four states from one contract — a KPI row is fed by an endpoint that can be slow, empty or down; status makes those three paths real branches beside ready, so nothing renders as a lonely 0 while the request is still in flight.
  • Skeleton mirrors anatomy — loading reuses the ready card's box and its three-row silhouette, so the header row occupies its final height from the first paint and the page below never jumps.
  • Trend is not the signtrend drives icon and tone while delta only supplies the number, which is what lets "churn down 2.1%" read as good news by remapping a single table entry.
  • Direction without color — the arrow is decorative and an sr-only word carries up/down/flat, so the delta stays legible to screen readers and to anyone who can't separate the green from the red.
  • Tabular figurestabular-nums fixes digit width so adjacent cards align on the decimal and a value ticking upward doesn't shuffle its own layout.
  • Retry belongs to the caller — the error branch renders a button only when onRetry exists; the component owns the presentation of failure and never the fetch.

On This Page