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
Lvalue 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
--accentand 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-3on the dashboard isviz-3in the drill-down. Assign by a stable key hash, never by array index.
Application rules
- Grayscale first. Build every component with zero color. Add color only where removing it loses information. Most components ship with none.
- 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.
- Backgrounds are tint, not fill. Status pills use
*-subtlebackgrounds with*-colored text, not saturated fills with white text. Saturated fills are reserved for the single primary action. - 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.
- No glow as affordance. A glow may never be the thing that says "this is interactive" or "this is focused".
- Borders carry the structure, not color. See elevation.
- 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.