Primitives
Build these first. Everything above them inherits their mistakes.
1. Money#
The core primitive. Every monetary value in the system renders through it.
<Money value={{ amount: 124056, currency: "USD" }} /> // $1,240.56
<Money value={{ amount: -124056, currency: "USD" }} accounting /> // ($1,240.56)
<Money value={null} /> // n/a
<Money value={{ amount: 0, currency: "USD" }} statement /> // —
interface MoneyProps extends React.ComponentPropsWithoutRef<"span"> {
value: MoneyValue | null;
/** parentheses for negatives (statements/tables) vs. a leading − (deltas) */
accounting?: boolean;
/** em dash for a true zero — statements only */
statement?: boolean;
size?: "xs" | "sm" | "md" | "lg" | "hero";
/** subordinate the symbol; default true */
showSymbol?: boolean;
/** show the ISO code — required in mixed-currency contexts */
showCode?: boolean;
/** mark as converted: renders "≈" and requires a rate for the tooltip */
approximate?: { rate: number; asOf: Date; from: string };
/** "$1.2M" via abbreviateMoney (never below 10K) — charts and KPIs only;
the exact value stays in the spoken label and the title */
compact?: boolean;
redacted?: boolean;
locale?: string;
}
Layer 3 — structure: [≈] [symbol] [integer] [.decimals] [code] — five slots, four optional. The paren column is reserved on both sides regardless of sign.
Layer 4 — type: one size, one weight. tnum + case always. Symbol and code at --text-tertiary; decimals at --text-tertiary only at lg/hero.
Motion: none, except the value flash when live and the crossfade when the value changes from a user action. Never interpolates.
Edge cases: JPY 0dp · KWD 3dp · bigint amounts · negative zero (renders as zero) · the widest-plausible-value width reservation · aria-label announcing the sign in words.
Why it matters: if this is wrong, everything is wrong. The rest of the library assumes the case/tnum/paren-alignment work done here.
2. Delta#
A signed change. Never colored without a redundant cue.
<Delta value={{ amount: 12400, currency: "USD" }} percent={5.3} polarity="standard" />
// ↑ +$12,400.00 (+5.3%)
<Delta value={{ amount: 8200, currency: "USD" }} polarity="inverted" />
// ↑ +$8,200.00 ← colored NEGATIVE, because up is bad for this metric
interface DeltaProps {
value: MoneyValue | null;
percent?: PercentChange; // { kind: "points" | "relative"; value: number }
polarity: "standard" | "inverted" | "neutral"; // required, no default
showArrow?: boolean; // default true
showPercent?: boolean;
basis?: string; // "vs. Aug" — never omit the comparison basis
}
Polarity is the point. A -4.2% change in burn is good. Default everything to standard and the dashboard shows green on a cost overrun.
API change: polarity is now required and has no default (it previously fell back to standard). Callers that omitted it must choose: standard for revenue, cash and margin; inverted for cost, expense, DSO and burn; neutral for an FX rate or a headcount.
Structure: [arrow] [sign] [value] [(percent)] [basis]. The sign slot is reserved so a column of deltas aligns even when some are unchanged.
Zero delta: — with --neutral-value, no arrow. Not +0.0%.
Motion: none. Value flash when live.
3. Figure#
A large display numeral. The only place a figure may leave the figure face.
<Figure value={{ amount: 24819024, currency: "USD" }} size="hero" family="display" />
The step-down: at hero (40px), a value exceeding a character threshold steps to lg (28px), then md (20px). A container query on character count drives it, not clamp(), so the value always lands on a type-scale token rather than between two.
The display gate: family="display" is only valid when the component is provably solitary. In KpiRow, where figures align across cards, the prop is ignored and the figure face is forced. The parent enforces this; don't trust the caller.
Never truncates. Escalation ladder in numerics.
4. Percent#
Percent and percentage points are different numbers. This component won't let you mix them up.
<Percent value={{ kind: "relative", value: 25.0 }} /> // +25.0%
<Percent value={{ kind: "points", value: 5.0 }} /> // +5.0pp
<Percent value={{ kind: "bps", value: 25 }} /> // +25 bps
kind is required and has no default. Division by zero renders n/a, never Infinity.
Precision by kind: relative/points 1dp · bps 0dp · rate 3dp.
5. Rule#
The accounting rule lines. Two CSS declarations, four centuries of grammar.
<Rule variant="subtotal" /> // border-top: 1px solid --border
<Rule variant="total" /> // border-bottom: 3px double --border-strong
<Rule variant="section" /> // full-bleed 1px --border-subtle
Spans the numeric column only, not the label column. That's what makes it read as accounting instead of a divider. In a multi-period statement it spans each period column independently, with the gutters unruled.
Never combined with a background fill on the same row.
No motion, no interactivity. It's ten lines of code and buys more credibility per byte than anything else in the library.
6. StatusPill#
<StatusPill status="pending" /> // ◷ Pending amber, subtle bg
<StatusPill status="settled" /> // ✓ Settled positive
<StatusPill status="failed" /> // ✕ Failed negative
<StatusPill status="reconciled" />
<StatusPill status="needs-review" />
*-subtle background, * text, never a saturated fill. Always icon plus text; an icon alone isn't a status. Accessible name includes the entity: "Payment pending".
State change: 140ms crossfade with a 2px blur mask, so the pill morphs instead of swapping.
7. Sparkline#
24–40px tall, no axes, no gridlines, no tooltip by default. --text-secondary stroke at 1.5px, or --positive/--negative by net direction, or neutral when polarity="neutral". polarity is required with no default, because a falling burn line is good news.
Optional endpoint dot and a single label at the last point.
Motion: stroke-dashoffset draw, 600ms --ease-out, once, on first paint or scroll-into-view ({ once: true }). Never on data change.
Touch: no scrub. A sparkline gives an impression. If the user needs values, use MoneyChart.
8. Trend#
Delta + Sparkline composed, with aligned baselines. The default "how is this doing" unit inside a card. polarity is required, no default, and is passed to both halves.
9. AsOf#
<AsOf date={lastSync} /> // "2m ago" · title="6 Sep 2026, 09:12 UTC"
Relative under an hour, absolute beyond. Timezone always stated in the absolute form. Updates from one shared interval, never one setInterval per instance, and pauses when the tab is hidden or the element is off-screen.
Every figure in the system carries one. A balance without an "as of" can't be trusted.
10. Redacted#
•••• at --text-tertiary, reveal on explicit action. Layout-stable: the masked form reserves the revealed width. Privacy-blur variant uses filter: blur(6px) + user-select: none, crossfading over 140ms so nothing reflows.
11. Reference#
Transaction and account IDs. "zero" 1, "ss02" 1 keeps 0/O and 1/l/I distinct, because people retype these into other systems. Click copies the raw value. Truncates in the middle (txn_8a2…f091), never at the end, because the tail is what tells two IDs apart.
12. CurrencyBadge#
ISO code, optionally with a flag. Flags are regional, not linguistic: EUR gets the EU flag, and a currency without a clean single-country mapping gets no flag instead of a wrong one. The code is always shown; the flag is decoration.
13. EntityMark#
Bank and merchant logos. Rounded-square container at radius 8, --surface-sunken background, ~20% inset, object-fit: contain, saturate(0.85) at row scale. Fallback ladder: provided → resolved → deterministic monogram → category icon. Never a broken image. Spec in imagery.
180. CardNetworkMark#
The network, at a glance, without a request or a broken image.
<CardNetworkMark network="amex" />
<CardNetworkMark network="visa" size={20} variant="mono" />
<CardNetworkMark network="Interac" /> {/* not drawn: prints the name */}
detectCardNetwork("3782 82") // "amex"
detectCardNetwork("3") // null: Amex, Diners Club or JCB
interface CardNetworkMarkProps extends Omit<React.ComponentPropsWithoutRef<"svg">, "children"> {
network: "visa" | "mastercard" | "amex" | "discover" | "jcb" | "unionpay" | "diners" | (string & {});
/** Height in px; the width follows at 5:3. Default 24. */
size?: number;
variant?: "color" | "mono";
/** The accessible name. Defaults to the network's: "American Express". */
label?: string;
}
One artboard. All seven are drawn on the same 40 × 24 box, so a row of them shares a height, a baseline and an optical weight. A logo dropped in at its own aspect ratio makes Mastercard's circles look large beside Visa's letters.
Schematic, not artwork. Each mark is a few shapes and at most one word: Mastercard's
overlapping circles, the Visa letters on a navy field, the Amex box, Discover's disc in place of
the O, the JCB bands, the UnionPay bands, the Diners Club ring. They're recognizable at 16px and
make no attempt to reproduce the network's artwork. A product licensed to show the official mark
passes it where a component takes one (PaymentMethodCard's networkLogo, CardInput's
networkMarks).
mono drops every field and draws the rest in currentColor, so the mark takes the ink of
what it sits on: a card face, a muted row, a dark surface.
A text fallback, never a wrong mark. A network it doesn't draw prints its name in a pill of the same height. A blank space or a generic card icon would leave the reader guessing which card it is.
Detection (detectCardNetwork in lib/card-network.ts) reads the issuer identification
number against the networks' published ranges: Visa 4 · Mastercard 51–55, 2221–2720 · Amex 34,
37 · Discover 6011, 644–649, 65 · JCB 3528–3589 · UnionPay 62 · Diners Club 300–305, 3095, 36,
38–39. A prefix only claims a network when every number it could still become falls inside that
network's range. "3" stays null until the next digit settles it, and "27" stays null until
the fourth, because only 2700–2720 of the 27xx block is Mastercard. The same module carries the
lengths (Amex 15, Diners Club 14, the rest 16), the security-code length (Amex 4, the rest 3) and
the printed grouping (4-6-5, 4-6-4, fours).
Accessibility: role="img" with the network's name. Pass aria-hidden where the name is
already in the surrounding text, such as a field that says "Visa card".
Motion: none. A mark is identity, not feedback.
169. Tooltip#
A restatement of something already on screen. Never the only home of a fact.
<Tooltip content="30 Sep 2026, 09:12 UTC">
<time>2m ago</time>
</Tooltip>
<TooltipProvider delay={400}>{/* a toolbar, a table, an app */}</TooltipProvider>
interface TooltipProps {
/** Nullable on purpose: a truncated cell has one, a short cell does not. */
content: React.ReactNode;
children: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
/** Overrides the provider's delay for this one trigger. */
delay?: number;
disabled?: boolean;
/** `block` for a child that fills its column — a truncating cell. */
display?: "inline" | "block";
triggerClassName?: string;
}
It renders its own trigger span and puts children inside it instead of cloning the
caller's element. A Server Component passes its JSX children across the boundary as a lazy
node, not an element (isValidElement is false and type is undefined), so a
render={children} clone renders undefined and crashes the page. Rendering the child as a child
works from either side of the boundary. It costs one <span>; display="block" lets a truncating
child keep its width.
What may go in one. The full figure behind $1.2M, the absolute timestamp behind "2m ago",
the untruncated merchant name behind an ellipsis, the rate and instant behind a ≈ conversion, or
the comparison basis behind a delta on a card too narrow to show it inline. A tooltip restates; it
never introduces.
What may not. Anything a user must read to act. Tooltips don't exist on touch, so a phone
user never sees a sentence that lives only in one. If it's essential, inline it. If it's extra, put
it in an InfoTip, which opens on press.
Layer 3 — structure: radius 8, --surface-raised, hairline + --elevation-2, 12px caption,
max-width 264px, 8px from its anchor.
Motion: enter opacity 0→1, scale 0.97→1 over 140ms --ease-out; exit opacity over 100ms;
400ms delay before the first one. Once one is open, hovering a neighbor opens it instantly.
Base UI sets data-instant, and the transition duration must drop to 0ms with it or the
"instant" tooltip still fades. Wrap a dense surface in TooltipProvider to get that grouping.
Accessibility: the trigger keeps its own accessible name; the tooltip is aria-describedby.
It opens on keyboard focus, closes on Escape, and is never the target of a click.
170. InfoTip#
The ⓘ. Additional context, reachable on every input.
<InfoTip about="Why this account needs reconnecting" label="This balance may be out of date">
Mercury revoked this connection's authorization. Last synced 21 Sep 2026, 09:12 UTC.
</InfoTip>
interface InfoTipProps {
/** An optional bolded first line. A term; omit it for a plain sentence. */
label?: React.ReactNode;
children: React.ReactNode;
/** The affordance's accessible name — "About available balance". */
about: string;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
/** Replaces the ⓘ glyph — a warning triangle beside a broken state, say. */
trigger?: React.ReactNode;
}
A Popover, not a Tooltip. It opens on hover with a fine pointer, on press everywhere, and on
keyboard focus, so the sentence inside is reachable on a phone. That's why it can hold a real
sentence. Escape and an outside press close it. 16px glyph, 40px hit area, --text-tertiary at rest.
Use it to shorten an alert. "Reauthorization required — this balance may be out of date" in a
one-third-width card wraps to three lines and reads as a paragraph in a strip meant to show a
state. "Reauthorization required" plus an ⓘ fits on one line, and the explanation is one press
away instead of always in the way.
Never put an action, a link the user must follow, or the only copy of a number inside one.