# Stat Card

> Single-metric dashboard card — value, label, delta, and a dependency-free SVG sparkline with polarity-aware coloring.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/stat-card).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/stat-card
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/stat-card-light-96e8f30f7c045d08.png
- Registry item: https://ui.ilinxa.com/r/stat-card.json
- Categories: data
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/stat-card.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `skeleton`

## Files

### `src/registry/components/data/stat-card/stat-card.tsx`

```tsx
import type { ElementType, MouseEvent } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { defaultDeltaFormat } from "./lib/format-default";
import { StatCardSparkline } from "./parts/sparkline";
import {
  DEFAULT_STAT_CARD_LABELS,
  type StatCardDelta,
  type StatCardLabels,
  type StatCardProps,
  type StatCardVariant,
} from "./types";

type DeltaTone = "good" | "bad" | "neutral";
type DeltaDirection = "up" | "down" | "neutral";

function deltaDirection(value: number): DeltaDirection {
  if (value > 0) return "up";
  if (value < 0) return "down";
  return "neutral";
}

function deltaTone(direction: DeltaDirection, betterIsHigher: boolean): DeltaTone {
  if (direction === "neutral") return "neutral";
  if (direction === "up") return betterIsHigher ? "good" : "bad";
  return betterIsHigher ? "bad" : "good";
}

// pro-ui doesn't ship a dedicated `--success` token; using `text-chart-2`
// (calibrated green-leaning hue from the existing chart palette) for the
// "good" tone keeps the component themable via existing tokens. Consumers
// who want a custom green pass `deltaClassName`.
const TONE_CLASSES: Record<DeltaTone, string> = {
  good: "text-chart-2",
  bad: "text-destructive",
  neutral: "text-muted-foreground",
};

interface DeltaRowProps {
  delta: StatCardDelta;
  labels: Required<StatCardLabels>;
  className?: string;
}

function DeltaRow({ delta, labels, className }: DeltaRowProps) {
  const dir = deltaDirection(delta.value);
  const tone = deltaTone(dir, delta.betterIsHigher ?? true);
  const formatted = (delta.format ?? defaultDeltaFormat)(delta.value);
  const period = delta.period ?? labels.deltaPeriod;
  const srLabel =
    dir === "up"
      ? labels.increaseLabel
      : dir === "down"
        ? labels.decreaseLabel
        : null;

  return (
    <div
      className={cn(
        "flex flex-wrap items-baseline gap-x-1.5 text-xs",
        TONE_CLASSES[tone],
        className,
      )}
    >
      <span aria-hidden="true" className="font-medium">
        {dir === "up" ? "↑" : dir === "down" ? "↓" : "→"}
      </span>
      <span className="font-medium">{formatted}</span>
      {srLabel ? <span className="sr-only">{srLabel}</span> : null}
      <span className="text-muted-foreground">
        {labels.deltaPrefix} {period}
      </span>
    </div>
  );
}

interface VariantSpec {
  /** Outer card padding + min-height (skeleton match). */
  padding: string;
  minHeight: string;
  /** Value text size. */
  valueSize: string;
  labelSize: string;
  /** Whether sparkline renders for this variant. */
  showSparkline: boolean;
  /** Default sparkline height in px (only used when sparkline renders). */
  sparklineHeight: number;
  /** Icon container size. */
  iconBoxSize: string;
  iconSize: string;
}

const VARIANT_SPEC: Record<StatCardVariant, VariantSpec> = {
  default: {
    padding: "p-5",
    minHeight: "min-h-[7.5rem]",
    valueSize: "text-2xl font-bold",
    labelSize: "text-sm",
    showSparkline: true,
    sparklineHeight: 28,
    iconBoxSize: "h-9 w-9 rounded-md bg-muted",
    iconSize: "h-4 w-4",
  },
  compact: {
    padding: "p-4",
    minHeight: "min-h-[5rem]",
    valueSize: "text-xl font-semibold",
    labelSize: "text-xs",
    showSparkline: false,
    sparklineHeight: 0,
    iconBoxSize: "h-8 w-8 rounded-md bg-muted",
    iconSize: "h-4 w-4",
  },
  detailed: {
    padding: "p-6",
    minHeight: "min-h-[10rem]",
    valueSize: "text-3xl font-bold",
    labelSize: "text-sm",
    showSparkline: true,
    sparklineHeight: 36,
    iconBoxSize: "h-11 w-11 rounded-lg bg-primary/10",
    iconSize: "h-5 w-5 text-primary",
  },
};

// Per-variant skeleton block sizes — hardcoded rather than derived from
// VARIANT_SPEC to avoid string-replace surgery on iconBoxSize (which contained
// `bg-primary/10` in the detailed variant, conflicting with Skeleton's own
// pulse-animation styling). See v0.1.0 review F-02.
const SKELETON_SIZES: Record<
  StatCardVariant,
  { iconBox: string; value: string; sparkline: string }
> = {
  default: { iconBox: "h-9 w-9 rounded-md", value: "h-7 w-24", sparkline: "h-7 w-full" },
  compact: { iconBox: "h-8 w-8 rounded-md", value: "h-6 w-20", sparkline: "h-7 w-full" },
  detailed: { iconBox: "h-11 w-11 rounded-lg", value: "h-9 w-32", sparkline: "h-9 w-full" },
};

function StatCardSkeleton({
  variant,
  labels,
  className,
}: {
  variant: StatCardVariant;
  labels: Required<StatCardLabels>;
  className?: string;
}) {
  const spec = VARIANT_SPEC[variant];
  const sk = SKELETON_SIZES[variant];
  return (
    // aria-busy on a generic div is sufficient — no role="region" (which
    // would require an accessible name we can't supply when loadingLabel
    // is a ReactNode). The sr-only span provides the spoken announcement.
    // See v0.1.0 review F-01.
    <div
      aria-busy="true"
      className={cn(
        "rounded-2xl border border-border bg-card text-card-foreground",
        spec.padding,
        spec.minHeight,
        className,
      )}
    >
      <span className="sr-only">{labels.loadingLabel}</span>
      <div className="flex flex-col gap-3">
        <div className="flex items-center justify-between gap-3">
          <Skeleton className="h-3 w-24" />
          <Skeleton className={sk.iconBox} />
        </div>
        <Skeleton className={sk.value} />
        <Skeleton className="h-3 w-32" />
        {spec.showSparkline ? <Skeleton className={sk.sparkline} /> : null}
      </div>
    </div>
  );
}

interface IconBlockProps {
  spec: VariantSpec;
  Icon: NonNullable<StatCardProps["icon"]>;
}

function IconBlock({ spec, Icon }: IconBlockProps) {
  return (
    <div
      className={cn(
        "flex shrink-0 items-center justify-center text-muted-foreground",
        spec.iconBoxSize,
      )}
    >
      <Icon className={cn(spec.iconSize)} aria-hidden="true" />
    </div>
  );
}

export function StatCard(props: StatCardProps) {
  const {
    value,
    label,
    variant = "default",
    delta,
    trend,
    renderTrend,
    icon: Icon,
    iconPosition = "leading",
    formatValue,
    renderValue,
    href,
    linkComponent,
    onClick,
    loading = false,
    labels: labelsProp,
    className,
    valueClassName,
    labelClassName,
    deltaClassName,
    ariaLabel,
  } = props;

  const labels: Required<StatCardLabels> = {
    ...DEFAULT_STAT_CARD_LABELS,
    ...labelsProp,
  };

  if (loading) {
    return (
      <StatCardSkeleton variant={variant} labels={labels} className={className} />
    );
  }

  const spec = VARIANT_SPEC[variant];

  // Resolve the value cell's content. Priority: renderValue > formatValue > raw.
  // value === undefined renders the empty-state placeholder.
  const isEmpty = value === undefined;
  const valueNode = isEmpty
    ? labels.emptyValueLabel
    : renderValue
      ? renderValue({ value, loading: false })
      : formatValue
        ? formatValue(value)
        : String(value);

  // Trend rendering — only when the variant shows it AND data exists.
  let trendNode: React.ReactNode = null;
  if (spec.showSparkline) {
    if (renderTrend && trend) {
      trendNode = renderTrend({ data: trend, variant });
    } else if (trend && trend.length >= 2) {
      trendNode = (
        <StatCardSparkline
          data={trend}
          height={spec.sparklineHeight}
          className={cn(
            "w-full text-primary",
            variant === "detailed" ? "h-9" : "h-7",
          )}
        />
      );
    }
  }

  const showIconLeading = Icon && iconPosition === "leading";
  const showIconTrailing = Icon && iconPosition === "trailing";

  // Body — same shape across variants; spacing differs via spec.
  const body = (
    <>
      {/* Top row: label + (trailing icon) */}
      <div className="flex items-start justify-between gap-3">
        <dt
          className={cn(
            "font-medium text-muted-foreground",
            spec.labelSize,
            labelClassName,
          )}
        >
          {label}
        </dt>
        {showIconTrailing && Icon ? <IconBlock spec={spec} Icon={Icon} /> : null}
      </div>

      {/* Middle row: leading icon + big value */}
      <div className="flex items-center gap-3">
        {showIconLeading && Icon ? <IconBlock spec={spec} Icon={Icon} /> : null}
        <dd
          className={cn(
            "text-foreground tabular-nums",
            spec.valueSize,
            isEmpty && "text-muted-foreground",
            valueClassName,
          )}
        >
          {valueNode}
        </dd>
      </div>

      {/* Delta row */}
      {delta ? (
        <dd className="m-0">
          <DeltaRow delta={delta} labels={labels} className={deltaClassName} />
        </dd>
      ) : null}

      {/* Trend / sparkline */}
      {trendNode ? (
        <div
          className={cn(
            "mt-auto pt-2",
            variant === "detailed" ? "pt-3" : "pt-2",
          )}
        >
          {trendNode}
        </div>
      ) : null}
    </>
  );

  // Linked variant — overlay-link pattern. <dl> is the semantic root; the
  // <linkComponent> covers it for click capture and gets the focus ring.
  if (href) {
    const LinkComponent: ElementType = linkComponent ?? "a";
    const handleClick = onClick
      ? (event: MouseEvent) => onClick({ event })
      : undefined;
    // Resolve the link's accessible name. Priority:
    //   1. Consumer-supplied `ariaLabel` — wins absolutely.
    //   2. `${label}: ${value}` — when both are stringifiable.
    //   3. `${label}` only — when label is stringifiable but value isn't
    //      (notably value === undefined). v0.1.1 fix per review F-03 — the
    //      v0.1.0 implementation fell through to undefined here, leaving
    //      a linked card with no accessible name.
    //   4. undefined — when the label itself is a ReactNode and not a
    //      string. Consumer should supply ariaLabel explicitly in that case.
    const computedAriaLabel =
      ariaLabel ??
      (typeof label === "string"
        ? typeof value === "string" || typeof value === "number"
          ? `${label}: ${value}`
          : label
        : undefined);

    return (
      <div
        className={cn(
          "relative rounded-2xl border border-border bg-card text-card-foreground",
          "shadow-sm motion-safe:hover:shadow-md transition-shadow duration-200",
          "focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
          spec.padding,
          spec.minHeight,
          className,
        )}
      >
        <dl className="flex h-full flex-col gap-2.5">{body}</dl>
        <LinkComponent
          href={href}
          aria-label={computedAriaLabel}
          onClick={handleClick}
          className="absolute inset-0 z-0 rounded-2xl outline-none"
        />
      </div>
    );
  }

  // Pure-display variant — no link wrapper, no focus ring.
  return (
    <dl
      className={cn(
        "flex flex-col gap-2.5 rounded-2xl border border-border bg-card text-card-foreground",
        spec.padding,
        spec.minHeight,
        className,
      )}
    >
      {body}
    </dl>
  );
}

export default StatCard;
```

### `src/registry/components/data/stat-card/index.ts`

```ts
export { StatCard, default } from "./stat-card";
export { StatCardSparkline } from "./parts/sparkline";
export type { StatCardSparklineProps } from "./parts/sparkline";
export { defaultDeltaFormat } from "./lib/format-default";
export { DEFAULT_STAT_CARD_LABELS } from "./types";
export type {
  StatCardDelta,
  StatCardIcon,
  StatCardIconPosition,
  StatCardLabels,
  StatCardProps,
  StatCardTrendContext,
  StatCardValueContext,
  StatCardVariant,
} from "./types";
// NOTE: `meta` is intentionally NOT re-exported — it's docs-site only,
// excluded from registry shipments per the post-Phase-7 cleanup
// (see .claude/decisions/ for the barrel-meta-export removal).
```

### `src/registry/components/data/stat-card/types.ts`

```ts
import type { ComponentType, ElementType, MouseEvent, ReactNode } from "react";

/** Visual variant. Default: 'default'. */
export type StatCardVariant = "default" | "compact" | "detailed";

/** Lucide-style icon prop. */
export type StatCardIcon = ComponentType<{ className?: string }>;

/** Position for the optional icon. Default: 'leading'. */
export type StatCardIconPosition = "leading" | "trailing";

/**
 * Delta payload — change since some prior period. Object-shape;
 * F-cross-12 lessons applied at construction.
 */
export interface StatCardDelta {
  /**
   * Signed number representing the change. **Convention: a fraction in 0–1
   * range representing a percentage** (`0.124` = +12.4%, `-0.08` = −8.0%).
   * The default `format` callback assumes this convention and renders via
   * `Intl.NumberFormat` with `style: "percent"`. For absolute counts
   * ("+1,240 users") or other units, override `format`.
   */
  value: number;
  /**
   * Custom formatter. Default: locale-aware
   * `Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" })`
   * → produces `+12.4%` / `−8.0%` / `0%`. Override for non-percentage units.
   */
  format?: (value: number) => string;
  /**
   * "Is up good?" — default `true`. Set `false` for cost / error metrics
   * where ↑ is bad (colors the upward delta red instead of green).
   */
  betterIsHigher?: boolean;
  /**
   * Optional period label rendered after the formatted delta value
   * (e.g. "vs last 30 days", "since Tuesday", "from peak"). ReactNode —
   * consumer owns the copy. Default: `labels.deltaPeriod` ("last period").
   */
  period?: ReactNode;
}

/** Localized labels — defaults English. */
export interface StatCardLabels {
  /** Default: 'vs'. Rendered between formatted delta value and period text. */
  deltaPrefix?: ReactNode;
  /** Default: 'last period'. Used as `delta.period` fallback when none supplied. */
  deltaPeriod?: ReactNode;
  /** Default: 'Loading metric…'. Visually-hidden announcement on the loading skeleton. */
  loadingLabel?: ReactNode;
  /** Default: 'increase'. Visually-hidden screen-reader text for positive deltas. */
  increaseLabel?: ReactNode;
  /** Default: 'decrease'. Visually-hidden screen-reader text for negative deltas. */
  decreaseLabel?: ReactNode;
  /** Default: '—'. Rendered at value position when `value === undefined`. */
  emptyValueLabel?: ReactNode;
}

/** Render-slot context for `renderValue`. */
export interface StatCardValueContext {
  value: number | string;
  /** True when this is the value rendered inside the loading skeleton (consumer-side rare). */
  loading: boolean;
}

/** Render-slot context for `renderTrend` (escape hatch for non-default chart shapes). */
export interface StatCardTrendContext {
  data: ReadonlyArray<number>;
  variant: StatCardVariant;
}

export interface StatCardProps {
  // ─── Required ────────────────────────────────────────────────────
  /** Big number. Pass primitive (formatted via `formatValue`) or any ReactNode via `renderValue`. */
  value: number | string | undefined;
  /** What this number is. Required. */
  label: ReactNode;

  // ─── Variant ─────────────────────────────────────────────────────
  /** Visual variant. Default: 'default'. */
  variant?: StatCardVariant;

  // ─── Delta ───────────────────────────────────────────────────────
  /** Optional delta payload. Absent → delta row not rendered. */
  delta?: StatCardDelta;

  // ─── Trend / sparkline ───────────────────────────────────────────
  /**
   * Sparkline data — equal-spaced numeric values. Default rendering: pure-SVG
   * single-line chart (no charting peer dep). Empty array or single point → no
   * chart (graceful skip). Cap at 100 points (sampled if more).
   */
  trend?: ReadonlyArray<number>;
  /** Full-takeover render slot for trend visualization (e.g., bars, dual-axis). Receives raw data. */
  renderTrend?: (ctx: StatCardTrendContext) => ReactNode;

  // ─── Icon ────────────────────────────────────────────────────────
  /** Optional decorative icon. Lucide-style component. */
  icon?: StatCardIcon;
  /** Position for the icon. Default: 'leading'. */
  iconPosition?: StatCardIconPosition;

  // ─── Formatters / slots ──────────────────────────────────────────
  /** Format the big value for display. Default: `String(value)` (no formatting). */
  formatValue?: (value: number | string) => ReactNode;
  /** Full-takeover slot for the value cell (overrides `formatValue`). */
  renderValue?: (ctx: StatCardValueContext) => ReactNode;

  // ─── Linking (overlay-link pattern) ──────────────────────────────
  /** URL the card links to. Pairs with `linkComponent`. */
  href?: string;
  /** Element used for the link wrapper. Default: 'a'. */
  linkComponent?: ElementType;
  /**
   * Click handler — F-cross-12-shape (object). Fired before navigation if
   * `href` is also set.
   */
  onClick?: (args: { event: MouseEvent }) => void;

  // ─── Loading / empty states ──────────────────────────────────────
  /** When true, renders a skeleton at the same shape as the loaded card. */
  loading?: boolean;

  // ─── i18n ────────────────────────────────────────────────────────
  labels?: StatCardLabels;

  // ─── Theming / overrides ─────────────────────────────────────────
  /** Override classes for the root <dl> / <a>. */
  className?: string;
  /** Override classes for the value cell. */
  valueClassName?: string;
  /** Override classes for the label cell. */
  labelClassName?: string;
  /** Override classes for the delta row. */
  deltaClassName?: string;

  // ─── Accessibility ───────────────────────────────────────────────
  /**
   * Override the link's accessible name. Default: derived from
   * `${label}: ${formatted-value}`. Only used when `href` is set.
   */
  ariaLabel?: string;
}

/** Default English labels — exported for consumer composition. */
export const DEFAULT_STAT_CARD_LABELS: Required<StatCardLabels> = {
  deltaPrefix: "vs",
  deltaPeriod: "last period",
  loadingLabel: "Loading metric…",
  increaseLabel: "increase",
  decreaseLabel: "decrease",
  emptyValueLabel: "—",
};
```

### `src/registry/components/data/stat-card/parts/sparkline.tsx`

```tsx
/**
 * Pure-SVG single-line sparkline. Sibling export of stat-card; consumers can
 * use it standalone via `<StatCardSparkline data={[...]}>`.
 *
 * Server-renderable (no state, no client). aria-hidden — the meaningful
 * summary belongs to the surrounding label/value/delta context.
 */

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

export interface StatCardSparklineProps {
  /** Equal-spaced numeric values. */
  data: ReadonlyArray<number>;
  /** Default: 32. SVG viewBox height; visual height controlled by className. */
  height?: number;
  /** Default: 96. SVG viewBox width; visual width controlled by className. */
  width?: number;
  /** Default: `currentColor` (inherits parent text color). */
  strokeColor?: string;
  /** Default: 1.5. */
  strokeWidth?: number;
  /** Tailwind classes for the wrapping <svg>; size/color overrides land here. */
  className?: string;
}

const MAX_POINTS = 100;

function downsample(data: ReadonlyArray<number>): ReadonlyArray<number> {
  if (data.length <= MAX_POINTS) return data;
  // Uniform sampling — pick every Nth point so the trend shape is preserved.
  const step = data.length / MAX_POINTS;
  const out: number[] = [];
  for (let i = 0; i < MAX_POINTS; i++) out.push(data[Math.floor(i * step)]);
  // Always include the last point so the rightmost edge reflects the latest value.
  out[MAX_POINTS - 1] = data[data.length - 1];
  return out;
}

export function StatCardSparkline({
  data,
  height = 32,
  width = 96,
  strokeColor = "currentColor",
  strokeWidth = 1.5,
  className,
}: StatCardSparklineProps) {
  // Skip render for empty / single-point datasets.
  if (data.length < 2) return null;

  const points = downsample(data);
  const min = Math.min(...points);
  const max = Math.max(...points);
  const range = max - min;

  // Constant data → render a flat line at vertical center.
  const yFor = (v: number) =>
    range === 0 ? height / 2 : height - ((v - min) / range) * height;

  const xStep = width / (points.length - 1);
  const path = points
    .map((v, i) => `${i === 0 ? "M" : "L"} ${(i * xStep).toFixed(2)} ${yFor(v).toFixed(2)}`)
    .join(" ");

  return (
    <svg
      aria-hidden="true"
      viewBox={`0 0 ${width} ${height}`}
      className={cn("overflow-visible", className)}
      preserveAspectRatio="none"
    >
      <path
        d={path}
        fill="none"
        stroke={strokeColor}
        strokeWidth={strokeWidth}
        strokeLinejoin="round"
        strokeLinecap="round"
        vectorEffect="non-scaling-stroke"
      />
    </svg>
  );
}
```

### `src/registry/components/data/stat-card/lib/format-default.ts`

```ts
// Locale-aware default delta formatter. Convention: `delta.value` is a
// fraction (0.124 = +12.4%); the percent style multiplies by 100 internally.
// `signDisplay: "exceptZero"` produces "+12.4%" / "−8.0%" / "0%" — the sign
// communicates direction; the visual arrow + sr-only label add semantics.
const enPercentFormatter = new Intl.NumberFormat(undefined, {
  style: "percent",
  maximumFractionDigits: 1,
  signDisplay: "exceptZero",
});

/** Default delta formatter — convention: value is a fraction (0.124 = 12.4%). */
export function defaultDeltaFormat(value: number): string {
  return enPercentFormatter.format(value);
}
```
