Ledger
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.
motionfree
"use client";
import { motion, useInView } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Scriber, type ScriberProps } from "@/registry/default/data/scriber/scriber";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { Tally } from "@/registry/default/text/tally/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. @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;
}
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,
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}
/>
);
// ---- 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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/ledgerManual — install dependencies, then copy the source
npm install motionProps
| 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. |
| 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. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.