---
title: "Primitives"
description: "Build these first. Everything above them inherits their mistakes. Specs for 16 React components: Money, Delta, Figure, Percent and 12 more."
canonical: "https://lopifi.com/docs/catalog/primitives"
---

# Primitives

> Build these first. Everything above them inherits their mistakes. Specs for 16 React components: Money, Delta, Figure, Percent and 12 more.

Source: https://lopifi.com/docs/catalog/primitives
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# Primitives

Build these first. Everything above them inherits their mistakes.

---

## 1. `Money`

> The core primitive. Every monetary value in the system renders through it.

```tsx
<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 />    // —
```

```ts
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](https://lopifi.com/md/docs/motion/catalog#value-flash) when `live` and the [crossfade](https://lopifi.com/md/docs/motion/catalog) 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.

```tsx
<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
```

```ts
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.

```tsx
<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](https://lopifi.com/md/docs/foundations/numerics#overflow).

---

## 4. `Percent`

> Percent and percentage points are different numbers. This component won't let you mix them up.

```tsx
<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.

```tsx
<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`

```tsx
<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`

```tsx
<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](https://lopifi.com/md/docs/foundations/imagery-and-isometric#logo-treatment).

---

## 180. `CardNetworkMark`

> The network, at a glance, without a request or a broken image.

```tsx
<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
```

```ts
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.

```tsx
<Tooltip content="30 Sep 2026, 09:12 UTC">
  <time>2m ago</time>
</Tooltip>

<TooltipProvider delay={400}>{/* a toolbar, a table, an app */}</TooltipProvider>
```

```ts
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`](https://lopifi.com/md/docs/catalog/primitives#170-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.

```tsx
<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>
```

```ts
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.
