# Ledger — Animated Data Visualization for React

> The atomic KPI stat card, animated in three beats: the value rolls up on odometer digit drums, the sparkline scores itself in, and the delta badge stamps down last with one crisp X-flip. Live updates re-roll only the digits that changed — a meter, not a poster.

- Page: https://crucibleui.com/components/data/ledger
- Category: [data](https://crucibleui.com/components/data)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/ledger
```

Registry JSON: https://crucibleui.com/r/ledger.json

Import from `@/components/crucible/ledger`.

## Props (`LedgerProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | — | KPI label rendered above the value, e.g. "Monthly revenue". |
| `value` | `number` | — | The metric. Changing it after the initial choreography is a live update: only the digits that changed re-roll (unchanged drums hold still), the badge re-stamps with a short tick, and no full re-choreography runs. |
| `format?` | `LedgerFormat` | `"number"` | How the value is formatted. `percent` follows Intl: pass `0.246` for "24.6%". |
| `currency?` | `string` | `"USD"` | ISO 4217 currency code used when `format` is `currency`. |
| `locale?` | `string` | `"en-US"` | BCP 47 locale for all number formatting. |
| `decimals?` | `number` | `0 (`number`), 2 (`currency`), 1 (`percent`)` | Decimal places on the value. |
| `delta?` | `number` | — | Change figure shown in the badge (percent points, e.g. `12.4` → "▴ 12.4%"). Omit for no badge. |
| `deltaDirection?` | `LedgerDirection` | — | Badge direction override; inferred from the sign of `delta` when omitted. |
| `deltaDecimals?` | `number` | `1` | Decimal places on the delta figure. |
| `sparkline?` | `number[]` | — | Trend points for the sparkline, any numeric range. Omit for no sparkline. New arrays swap in without redrawing. |
| `sparklineProps?` | `Omit<ScriberProps, "data">` | — | Advanced pass-through to the underlying Scriber (variant, colors, strokeWidth…). |
| `trigger?` | `LedgerTrigger` | `"in-view"` | What starts the three-beat choreography: entering the viewport, mounting, or the first `value` change. The digit roll itself always waits until the value is actually on screen (so it is never spent offscreen). |
| `delay?` | `number` | `0` | Seconds the whole choreography is shifted by — set `delay={i * 0.15}` across a row of ledgers to sequence a metrics strip. |
| `stagger?` | `number` | `0.75` | Seconds between beat starts (value → sparkline → badge). |
| `valueDuration?` | `number` | `1.4` | Seconds the value takes to roll up (beat one). |
| `sparklineDuration?` | `number` | `1.2` | Seconds the sparkline takes to draw (beat two). |
| `badgeDuration?` | `number` | `0.5` | Seconds the badge flip takes to land (beat three). |
| `size?` | `LedgerSize` | `"md"` | Overall scale of the card. |
| `colors?` | `LedgerColors` | — | Delta badge hues — the only color on the card. |
| `paused?` | `boolean` | `false` | Render the finished card with no animation at all. |
| `tint?` | `string` | `Tally's cool white-blue` | Forwarded to the value's Tally: hue of the entry cast, bloom and baseline glint. |
| `glow?` | `boolean` | `true` | Forwarded to the value's Tally: drum bloom + backlight. |
| `glint?` | `boolean` | `true` | Forwarded to the value's Tally: the baseline glint as the value settles. |
| `shade?` | `boolean` | `true` | Forwarded to the value's Tally: cylinder shading over each drum face. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Ledger } from "@/components/crucible/ledger";

const REVENUE = [31, 34, 33, 37, 36, 40, 43, 41, 46, 44, 49, 53, 51, 56, 60, 58];
const SESSIONS = [52, 55, 51, 58, 56, 61, 59, 64, 62, 66, 63, 68, 71, 69, 74, 72];
const BOUNCE = [88, 84, 86, 81, 83, 78, 80, 76, 74, 77, 72, 70, 73, 68, 66, 69];

const SESSIONS_BASE = 11690;

/** Deterministic PRNG so the live card walks the same path every time. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export default function LedgerDemo() {
  const [live, setLive] = React.useState({ value: 12483, delta: 6.8, trend: SESSIONS });

  // Seeded live feed: every few seconds the middle card receives a new value —
  // only the digits that changed re-roll, the badge re-stamps, the trend gains
  // a point. No full re-choreography.
  React.useEffect(() => {
    const rnd = mulberry32(21);
    const id = window.setInterval(() => {
      setLive((prev) => {
        const value = Math.max(9400, Math.round(prev.value + (rnd() - 0.42) * 900));
        const delta = Math.round(((value - SESSIONS_BASE) / SESSIONS_BASE) * 1000) / 10;
        const point = Math.round((40 + (value - 9400) / 140) * 10) / 10;
        return { value, delta, trend: [...prev.trend.slice(1), point] };
      });
    }, 2800);
    return () => window.clearInterval(id);
  }, []);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex w-full max-w-2xl flex-col gap-6 px-6">
        <div className="grid gap-3 sm:grid-cols-3">
          <Ledger
            label="Revenue"
            value={48250}
            format="currency"
            decimals={0}
            delta={12.4}
            sparkline={REVENUE}
            size="sm"
          />
          <Ledger
            label="Sessions"
            value={live.value}
            delta={live.delta}
            sparkline={live.trend}
            size="sm"
            delay={0.18}
          />
          <Ledger
            label="Bounce rate"
            value={0.246}
            format="percent"
            delta={-3.2}
            sparkline={BOUNCE}
            size="sm"
            delay={0.36}
          />
        </div>
        <p className="text-center text-[11px] font-medium uppercase tracking-[0.2em] text-neutral-600">
          Three beats on view: value rolls, trend draws, badge stamps last · middle card updates live
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`ledger.tsx`)

```tsx
"use client";

import { motion, useInView } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { Scriber, type ScriberProps } from "@/components/crucible/scriber";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Tally } from "@/components/crucible/tally";

export type LedgerFormat = "number" | "currency" | "percent";
export type LedgerDirection = "up" | "down" | "flat";
export type LedgerTrigger = "in-view" | "mount" | "prop-change";
export type LedgerSize = "sm" | "md" | "lg";

export interface LedgerColors {
  /** Delta badge hue when the direction is up. @default "#39c5b1" (restrained teal) */
  up?: string;
  /** Delta badge hue when the direction is down. @default "#e08563" (restrained rust) */
  down?: string;
  /** Delta badge hue when the direction is flat. @default "#a1a1aa" */
  flat?: string;
}

export interface LedgerProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /** KPI label rendered above the value, e.g. "Monthly revenue". */
  label: string;
  /**
   * The metric. Changing it after the initial choreography is a live update:
   * only the digits that changed re-roll (unchanged drums hold still), the
   * badge re-stamps with a short tick, and no full re-choreography runs.
   */
  value: number;
  /** How the value is formatted. `percent` follows Intl: pass `0.246` for "24.6%". @default "number" */
  format?: LedgerFormat;
  /** ISO 4217 currency code used when `format` is `currency`. @default "USD" */
  currency?: string;
  /** BCP 47 locale for all number formatting. @default "en-US" */
  locale?: string;
  /** Decimal places on the value. @default 0 (`number`), 2 (`currency`), 1 (`percent`) */
  decimals?: number;
  /** Change figure shown in the badge (percent points, e.g. `12.4` → "▴ 12.4%"). Omit for no badge. */
  delta?: number;
  /** Badge direction override; inferred from the sign of `delta` when omitted. */
  deltaDirection?: LedgerDirection;
  /** Decimal places on the delta figure. @default 1 */
  deltaDecimals?: number;
  /** Trend points for the sparkline, any numeric range. Omit for no sparkline. New arrays swap in without redrawing. */
  sparkline?: number[];
  /** Advanced pass-through to the underlying Scriber (variant, colors, strokeWidth…). */
  sparklineProps?: Omit<ScriberProps, "data">;
  /**
   * What starts the three-beat choreography: entering the viewport, mounting,
   * or the first `value` change. The digit roll itself always waits until the
   * value is actually on screen (so it is never spent offscreen). @default "in-view"
   */
  trigger?: LedgerTrigger;
  /**
   * Seconds the whole choreography is shifted by — set `delay={i * 0.15}`
   * across a row of ledgers to sequence a metrics strip. @default 0
   */
  delay?: number;
  /** Seconds between beat starts (value → sparkline → badge). @default 0.75 */
  stagger?: number;
  /** Seconds the value takes to roll up (beat one). @default 1.4 */
  valueDuration?: number;
  /** Seconds the sparkline takes to draw (beat two). @default 1.2 */
  sparklineDuration?: number;
  /** Seconds the badge flip takes to land (beat three). @default 0.5 */
  badgeDuration?: number;
  /** Overall scale of the card. @default "md" */
  size?: LedgerSize;
  /** Delta badge hues — the only color on the card. */
  colors?: LedgerColors;
  /** Render the finished card with no animation at all. @default false */
  paused?: boolean;
  /** Forwarded to the value's Tally: hue of the entry cast, bloom and baseline glint. @default Tally's cool white-blue */
  tint?: string;
  /** Forwarded to the value's Tally: drum bloom + backlight. @default true */
  glow?: boolean;
  /** Forwarded to the value's Tally: the baseline glint as the value settles. @default true */
  glint?: boolean;
  /** Forwarded to the value's Tally: cylinder shading over each drum face. @default true */
  shade?: boolean;
}

const SIZES = {
  sm: {
    card: "p-4",
    label: "text-[10px]",
    value: "text-2xl",
    valueMt: "mt-2",
    badge: "text-[10px]",
    spark: "h-8",
    sparkMt: "mt-3 pt-3",
  },
  md: {
    card: "p-5",
    label: "text-[11px]",
    value: "text-4xl",
    valueMt: "mt-3",
    badge: "text-[11px]",
    spark: "h-10",
    sparkMt: "mt-4 pt-4",
  },
  lg: {
    card: "p-6",
    label: "text-xs",
    value: "text-5xl",
    valueMt: "mt-4",
    badge: "text-xs",
    spark: "h-12",
    sparkMt: "mt-5 pt-5",
  },
} as const;

const DEFAULT_COLORS: Required<LedgerColors> = {
  up: "#39c5b1",
  down: "#e08563",
  flat: "#a1a1aa",
};

const ARROWS: Record<LedgerDirection, string> = { up: "▴", down: "▾", flat: "–" };

/**
 * Ledger — the atomic KPI stat card, animated in three beats instead of pasted
 * static: the value rolls up on odometer digit drums (Tally), the trend
 * sparkline scores itself in (Scriber), and the delta badge flips in last —
 * one crisp X-rotation with a one-frame settle, like a stamp landing. Neutral
 * typographic monochrome with a hairline rule; the badge is the only color on
 * the card. Live `value` changes re-roll only the digits that moved and
 * re-stamp the badge with a short tick — never a full re-choreography — and
 * the new value is announced politely. A `delay` shifts the whole timeline so
 * a row of ledgers sequences into a metrics strip. Reduced motion renders the
 * finished card: final value, drawn sparkline, static badge.
 */
export function Ledger({
  label,
  value,
  format = "number",
  currency = "USD",
  locale = "en-US",
  decimals,
  delta,
  deltaDirection,
  deltaDecimals = 1,
  sparkline,
  sparklineProps,
  trigger = "in-view",
  delay = 0,
  stagger = 0.75,
  valueDuration = 1.4,
  sparklineDuration = 1.2,
  badgeDuration = 0.5,
  size = "md",
  colors,
  paused = false,
  tint,
  glow,
  glint,
  shade,
  className,
  ...props
}: LedgerProps) {
  const reducedMotion = useReducedMotion();
  const staticMode = reducedMotion || paused;
  const S = SIZES[size];

  // ---- formatting -----------------------------------------------------------
  const dec = decimals ?? (format === "currency" ? 2 : format === "percent" ? 1 : 0);
  const formatOptions = React.useMemo<Intl.NumberFormatOptions | undefined>(() => {
    if (format === "currency") return { style: "currency", currency };
    if (format === "percent") return { style: "percent" };
    return undefined;
  }, [format, currency]);
  const formatted = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: dec,
        maximumFractionDigits: dec,
        ...formatOptions,
      }).format(value),
    [value, dec, locale, formatOptions]
  );

  const direction: LedgerDirection =
    deltaDirection ?? (delta == null || delta === 0 ? "flat" : delta > 0 ? "up" : "down");
  const deltaText = React.useMemo(() => {
    if (delta == null) return "";
    const n = new Intl.NumberFormat(locale, {
      minimumFractionDigits: 0,
      maximumFractionDigits: deltaDecimals,
    }).format(Math.abs(delta));
    return `${n}%`;
  }, [delta, locale, deltaDecimals]);
  const dirColor = colors?.[direction] ?? DEFAULT_COLORS[direction];

  // ---- choreography ----------------------------------------------------------
  // Beat starts: value at `delay`, sparkline at `delay + stagger`, badge one
  // stagger after the last beat before it — the judgment always arrives last.
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { once: true, amount: 0.35 });
  const [started, setStarted] = React.useState(false);
  const [sparkGo, setSparkGo] = React.useState(false);
  const initialValueRef = React.useRef(value);
  const stampedRef = React.useRef(false);

  React.useEffect(() => {
    if (started) return;
    if (trigger === "prop-change") {
      if (value !== initialValueRef.current) setStarted(true);
    } else if (trigger === "mount" || inView) {
      setStarted(true);
    }
  }, [started, trigger, inView, value]);

  React.useEffect(() => {
    if (!started || sparkGo) return;
    if (staticMode) {
      setSparkGo(true);
      return;
    }
    const t = window.setTimeout(() => setSparkGo(true), Math.max(0, (delay + stagger) * 1000));
    return () => window.clearTimeout(t);
  }, [started, sparkGo, staticMode, delay, stagger]);

  const hasSparkline = !!sparkline && sparkline.length >= 2;
  const badgeDelay = delay + stagger * (hasSparkline ? 2 : 1);

  // ---- value (beat one) -------------------------------------------------------
  // `prop-change` holds a static figure until the first update, then Tally
  // rolls from that figure to the new one. `paused` renders the final figure.
  const holdStatic = paused || (trigger === "prop-change" && !started);
  const valueNode = holdStatic ? (
    <span aria-hidden className="tabular-nums">
      {formatted}
    </span>
  ) : (
    <Tally
      aria-hidden
      value={value}
      start={trigger === "prop-change" ? initialValueRef.current : 0}
      decimals={dec}
      locale={locale}
      formatOptions={formatOptions}
      duration={valueDuration}
      delay={delay}
      tint={tint}
      glow={glow}
      glint={glint}
      shade={shade}
    />
  );

  // ---- badge (beat three) -------------------------------------------------------
  // First arrival is the stamp: a single crisp X-rotation past level (-7°) with
  // a one-frame settle back onto 0°. Later delta changes re-key the badge and
  // land a shorter, immediate tick — no replay of the full choreography.
  const isFirstStamp = !stampedRef.current;
  const badgeCls = cn(
    "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-[3px] font-medium leading-none tabular-nums",
    S.badge
  );
  const badgeStyle: React.CSSProperties = {
    color: dirColor,
    borderColor: `color-mix(in srgb, ${dirColor} 30%, transparent)`,
    backgroundColor: `color-mix(in srgb, ${dirColor} 9%, transparent)`,
  };
  const badgeInner = (
    <>
      <span className="text-[0.85em]">{ARROWS[direction]}</span>
      <span>{deltaText}</span>
    </>
  );
  const badge =
    delta == null ? null : staticMode ? (
      <span aria-hidden className={badgeCls} style={badgeStyle}>
        {badgeInner}
      </span>
    ) : (
      <motion.span
        aria-hidden
        key={`${direction}:${deltaText}`}
        className={cn(badgeCls, "will-change-[transform,opacity]")}
        style={{ ...badgeStyle, transformPerspective: 420 }}
        initial={{ opacity: 0, rotateX: isFirstStamp ? 92 : 58 }}
        animate={
          started
            ? { opacity: [0, 1, 1], rotateX: isFirstStamp ? [92, -7, 0] : [58, -5, 0] }
            : undefined
        }
        transition={{
          delay: isFirstStamp ? badgeDelay : 0,
          duration: isFirstStamp ? badgeDuration : Math.min(badgeDuration, 0.32),
          times: [0, 0.8, 1],
          ease: "easeOut",
        }}
        onAnimationComplete={() => {
          stampedRef.current = true;
        }}
      >
        {badgeInner}
      </motion.span>
    );

  // ---- sparkline (beat two) -----------------------------------------------------
  const { className: sparkClassName, ...sparkRest } = sparklineProps ?? {};

  // ---- a11y announcement -----------------------------------------------------------
  const dirWord = direction === "up" ? "up" : direction === "down" ? "down" : "flat";
  const announce = delta == null ? formatted : `${formatted}, ${dirWord} ${deltaText}`;

  return (
    <div
      ref={rootRef}
      data-crucible="ledger"
      className={cn(
        "relative flex flex-col rounded-lg border border-white/8 bg-white/1.5 text-left",
        S.card,
        className
      )}
      {...props}
    >
      <div className="flex items-start justify-between gap-3">
        <span className={cn("font-medium uppercase tracking-[0.18em] text-neutral-500", S.label)}>
          {label}
        </span>
        {badge}
      </div>

      <div className={cn("font-semibold leading-none tracking-tight text-white", S.valueMt, S.value)}>
        {valueNode}
      </div>

      {hasSparkline ? (
        <span aria-hidden className={cn("block border-t border-white/6", S.sparkMt)}>
          <Scriber
            variant="area"
            data={sparkline}
            {...sparkRest}
            drawDuration={paused ? 0 : sparklineDuration}
            paused={paused || !sparkGo}
            className={cn("block w-full", S.spark, sparkClassName)}
          />
        </span>
      ) : null}

      {/* Polite live region: the value (and judgment) read as real text and
          re-announce on every live update. */}
      <span role="status" className="sr-only">
        {label}: {announce}
      </span>
    </div>
  );
}
```
