Skip to content

Color

Grayscale first. Color is added only where it carries meaning, and in a financial interface almost nothing does.

The whole palette is one neutral ramp, one accent, and a semantic value pair. That's it. If a design needs a sixth hue, it needs a rethink instead.


Why OKLCH

All tokens are authored in oklch().

  • Perceptual uniformity. oklch(0.55 …) is the same apparent lightness at every hue. In HSL it is not, which is why hand-tuned HSL ramps always have one step that looks wrong.
  • Predictable ramps. Hold hue and chroma, vary lightness, get a ramp that reads as one family.
  • Contrast is inspectable. The L value approximates perceived lightness, so contrast failures are visible in the token file.
  • Wide gamut. P3 displays get more saturated accents for free, with sRGB fallback.

Fallback: ship oklch() directly. Baseline since 2023; for legacy targets a PostCSS plugin emits an sRGB fallback declaration above each.


The neutral ramp

Hue 265, chroma 0.002–0.012. A cool ink, not a pure grey. Pure #000/#fff/#808080 in a data-dense UI reads as unfinished; a whisper of blue reads as considered and matches how paper-white and ink actually look.

Chroma increases slightly as lightness decreases — dark neutrals need more chroma to avoid looking flat.

Light mode

--canvas:            oklch(0.985 0.002 265);  /* page background — NOT white */
--surface:           oklch(1.000 0.000 000);  /* cards — pure white, lifted above canvas */
--surface-sunken:    oklch(0.970 0.003 265);  /* wells, code, inactive tabs, table zebra */
--surface-hover:     oklch(0.975 0.003 265);
--surface-selected:  oklch(0.960 0.010 258);  /* accent-tinted */

--border-subtle:     oklch(0.935 0.004 265);  /* dividers INSIDE a surface */
--border:            oklch(0.900 0.005 265);  /* surface edges */
--border-strong:     oklch(0.820 0.006 265);  /* emphasis, selected, input hover */

--text-primary:      oklch(0.210 0.012 265);  /* 15.8:1 on surface */
--text-secondary:    oklch(0.480 0.010 265);  /*  7.4:1 — labels, metadata */
--text-tertiary:     oklch(0.580 0.008 265);  /*  4.6:1 — THE FLOOR for meaningful text */
--text-disabled:     oklch(0.720 0.006 265);  /*  2.6:1 — never used for meaning */
--text-inverse:      oklch(0.985 0.002 265);

Dark mode

Not an inversion — a separate design. Dark surfaces get lighter as they rise, which is the opposite of light mode, where surfaces get lighter as they rise too. (Both modes: elevation = more light. That's physically consistent and it's why naive inversion feels wrong.)

--canvas:            oklch(0.170 0.008 265);
--surface:           oklch(0.205 0.009 265);
--surface-raised:    oklch(0.240 0.010 265);  /* popovers, dialogs */
--surface-sunken:    oklch(0.150 0.008 265);
--surface-hover:     oklch(0.235 0.010 265);
--surface-selected:  oklch(0.265 0.020 258);

--border-subtle:     oklch(0.265 0.010 265);
--border:            oklch(0.310 0.011 265);
--border-strong:     oklch(0.400 0.012 265);

--text-primary:      oklch(0.970 0.003 265);
--text-secondary:    oklch(0.720 0.008 265);
--text-tertiary:     oklch(0.600 0.008 265);  /* floor: 4.6:1 on --surface */
--text-disabled:     oklch(0.450 0.008 265);

Dark mode is never pure black. oklch(0.17) ≈ #1c1c22. Pure black canvas with white text causes halation on OLED and makes 14px tabular figures shimmer. Ask any trader who's stared at a terminal.


The accent

One. --accent. Ink blue.

--accent:            oklch(0.500 0.155 258);  /* ≈ #2b5fd9 */
--accent-hover:      oklch(0.450 0.155 258);
--accent-active:     oklch(0.410 0.150 258);
--accent-subtle:     oklch(0.960 0.025 258);  /* tinted backgrounds */
--accent-border:     oklch(0.880 0.060 258);
--accent-fg:         oklch(0.995 0.000 000);  /* text ON accent */

/* dark mode */
--accent:            oklch(0.650 0.150 258);
--accent-subtle:     oklch(0.270 0.045 258);
--accent-fg:         oklch(0.170 0.010 258);

The one-accent law

One accent event per view. Spend it on the single most important action.

If a screen has a primary "Send payment" button, then the active nav item, the selected tab, the focused input ring, and the chart series may not also be accent-filled. They get neutral treatments: a border, a weight change, a background tint, an underline.

The exceptions — the only ones:

  • Focus rings. Accessibility overrides aesthetics. The focus ring is always --accent and it does not count toward the budget.
  • Links in prose. Body copy links are accent-colored. They're not competing for the same attention.

Accent is never a data color

Charts, category tags, and account colors do not use --accent. If everything can be blue, blue stops meaning "press this".


Semantic value colors

The most consequential decision in a finance palette.

--positive:          oklch(0.520 0.130 155);  /* deep teal-green */
--positive-fg:       oklch(0.995 0 0);
--positive-subtle:   oklch(0.950 0.035 155);
--positive-flash:    oklch(0.900 0.070 155);  /* live tick background */

--negative:          oklch(0.520 0.190 025);  /* deep rose-red */
--negative-fg:       oklch(0.995 0 0);
--negative-subtle:   oklch(0.955 0.030 025);
--negative-flash:    oklch(0.905 0.070 025);

--warning:           oklch(0.680 0.150 075);  /* amber — pending, review, expiring: fills and borders */
--warning-subtle:    oklch(0.960 0.045 075);
--warning-text:      oklch(0.520 0.115 070);  /* amber as text and glyphs: ≥ 4.96:1, --warning-subtle included */

--info:              var(--accent);
--neutral-value:     var(--text-secondary);   /* unchanged / zero delta */

Both --positive and --negative sit at L 0.52 — they are peers, neither shouts louder. Amber sits higher because it must be distinguishable from red at a glance — which is also why it cannot be text: --warning is 2.95:1 on white. Text and glyphs in amber use --warning-text, and the preset maps every text-warning utility to it, so bg-warning and border-warning keep the fill while no call site can set the fill as copy. In dark mode --warning-text is --warning itself (≥ 6.28:1), and --negative steps up to L 0.69 so it reads ≥ 4.8:1 on --negative-subtle.

The red/green problem, and the actual fix

Roughly 8% of men have a red-green deficiency. Two colors at the same lightness with red/green hues converge to the same grey for a deuteranope. So:

Color is never the only signal for a value's sign. Never. Not once, anywhere in this system.

Every signed value ships with a redundant, non-chromatic cue:

Cue Where
Explicit sign glyph — + prefix on positive deltas, − (U+2212, not a hyphen) on negatives Delta, all KPI changes
Accounting parentheses — (1,234.00) Statements, ledgers, tables
Directional arrow — ↑ / ↓ at 12px Delta, Trend
Position — above/below a zero baseline Charts
Pattern — 45° hatch fill on negative bars Charts, waterfalls

Test: turn the palette to greyscale and confirm every sign is still readable. That's the ship gate, and it's in the quality bar.

The Signal theme (accessibility variant)

An opt-in alternate mapping for users who tell us they need it, plus a system-level respect for prefers-contrast. Blue/amber is the canonical colorblind-safe divergent pair — different in hue and lightness.

[data-value-palette="signal"] {
  --positive: oklch(0.500 0.130 250);   /* blue */
  --negative: oklch(0.520 0.130 055);   /* amber-orange, deep enough to set figures: ≥ 4.98:1 */
  --positive-subtle: oklch(0.955 0.030 250);
  --negative-subtle: oklch(0.960 0.045 060);
}

The convention "green = up" is strong enough that we don't make this the default. It is a setting, surfaced in preferences next to density, and it must be honored by every chart as well as every number.

Sign is a domain decision, not a color decision

--negative means "the number is below zero", not "this is bad". A negative expense variance is good news. Components take a polarity prop:

polarity?: "standard" | "inverted" | "neutral"
// standard  → up is positive   (revenue, cash, margin)
// inverted  → up is negative   (burn, COGS, churn, DSO)
// neutral   → no color at all  (headcount, invoice count)

Defaulting every metric to standard is how dashboards end up cheerfully green about a cost overrun.


Data-visualization palette

Categorical series need to be distinguishable from each other and from the semantic pair. So they live in a different lightness band and avoid hues 155 and 025.

--viz-1: oklch(0.550 0.140 258);  /* blue      — always series 1 */
--viz-2: oklch(0.650 0.120 195);  /* cyan      */
--viz-3: oklch(0.620 0.130 090);  /* olive     */
--viz-4: oklch(0.580 0.140 320);  /* magenta   */
--viz-5: oklch(0.680 0.110 045);  /* sand      */
--viz-6: oklch(0.480 0.100 230);  /* deep blue */
--viz-7: oklch(0.720 0.090 140);  /* sage      */
--viz-8: oklch(0.420 0.080 300);  /* plum      */

Rules:

  • Max 6 categorical series. Beyond that, group into "Other" — this is a data problem, not a palette problem.
  • Sequential scales (heatmaps, calendar) interpolate a single hue's lightness in OKLCH: oklch(0.97 0.02 258) → oklch(0.40 0.16 258).
  • Diverging scales (variance) use blue→neutral→amber, never red→green, because a diverging scale has no redundant sign cue available.
  • Series order is stable across renders. A category that's viz-3 on the dashboard is viz-3 in the drill-down. Assign by a stable key hash, never by array index.

Application rules

  1. Grayscale first. Build every component with zero color. Add color only where removing it loses information. Most components ship with none.
  2. Never color as decoration on a data surface. Colored card headers, tinted table stripes, category-colored borders on every row — all forbidden. Color is bandwidth; spend it on the exception.
  3. Backgrounds are tint, not fill. Status pills use *-subtle backgrounds with * -colored text, not saturated fills with white text. Saturated fills are reserved for the single primary action.
  4. No gradients. None. Not on buttons, not on cards, not on charts. One exception: an area chart's fill may fade a single hue to transparent, because that's a legibility device (it keeps the line readable over gridlines), not decoration.
  5. No glow as affordance. A glow may never be the thing that says "this is interactive" or "this is focused".
  6. Borders carry the structure, not color. See elevation.
  7. Every token is defined for both modes at declaration. No token exists that's only valid in light mode.

Contrast requirements

Content Minimum Standard
Body text, all figures 4.5:1 WCAG AA
Text ≥ 18.66px bold or ≥ 24px 3:1 AA large
Any figure representing money 7:1 AAA — our own rule
Icons conveying meaning 3:1 AA non-text
Borders that define an input 3:1 AA non-text
Focus ring vs. adjacent 3:1 AA non-text
Chart series vs. background 3:1
Decorative borders/dividers none

The 7:1 rule for money is deliberate and stricter than the standard. A misread figure in a finance product is a material error, and --text-primary at 15.8:1 costs nothing.

Never use --text-tertiary for a monetary value. It's for labels, timestamps, and units — which is why the currency symbol may use it. The one sanctioned exception inside a figure is the cents of a figure-hero / figure-lg display value, where the glyphs are large text and the integer part carries the amount at full contrast (typography); in tables the cents stay at full colour.


Theming for consumers

The system ships one point of view. Consumers may override exactly two things without breaking it:

:root {
  --accent: oklch(0.50 0.16 25);   /* your brand hue */
  --font-display: "Your Serif";    /* your editorial face */
}

The neutral ramp, semantic values, spacing, radius and motion tokens are not theming surface. If you change those you have forked the system, which is fine — you own the code — but you're on your own for coherence.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.