Color
Grayscale first. Color goes only where it carries meaning, and in a financial interface almost nothing does.
The palette is one neutral ramp, one accent and a semantic value pair. A design that needs a sixth hue needs a rethink.
Why OKLCH#
All tokens are authored in oklch().
- Perceptual uniformity.
oklch(0.55 …)looks equally light at every hue. HSL doesn't, which is why hand-tuned HSL ramps always have one step that looks off. - Predictable ramps. Hold hue and chroma, vary lightness, and the ramp reads as one family.
- Contrast is inspectable.
Lapproximates perceived lightness, so you can spot contrast failures in the token file. - Wide gamut. P3 displays get more saturated accents for free, with sRGB fallback.
Ship oklch() directly. It's been Baseline since 2023. For legacy targets, a PostCSS plugin emits an sRGB fallback above each declaration.
The neutral ramp#
Hue 265, chroma 0.002–0.012. A cool ink, not pure gray. Pure #000/#fff/#808080 makes a data-dense UI look unfinished. A trace of blue looks deliberate and matches how real paper and ink look.
Chroma rises slightly as lightness drops, because dark neutrals look flat without it.
One neutral ramp
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#
Dark mode is a separate design, not an inversion. Dark surfaces get lighter as they rise, the same as in light mode: in both, elevation means more light. That's physically consistent, and it's why a 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. White text on a pure black canvas halates on OLED and makes 14px tabular figures shimmer.
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, the active nav item, selected tab, focused input ring and chart series can't also be accent-filled. They get neutral treatments instead: a border, a weight change, a background tint or an underline.
There are two exceptions:
- Focus rings. Accessibility wins. The focus ring is always
--accentand doesn't count toward the budget. - Links in prose. Body copy links are accent-colored. They don't compete for the same attention.
One accent per view
Accent is never a data color#
Charts, category tags and account colors don't use --accent. If everything can be blue, blue stops meaning "press this."
Semantic value colors#
The most important 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 */
--positive and --negative both sit at L 0.52. They're peers, and neither is louder. Amber sits higher so it's easy to tell from red at a glance, which is also why it can't be text: --warning is 2.95:1 on white. Amber text and glyphs use --warning-text. The preset maps every text-warning utility to it, so bg-warning and border-warning keep the fill and no call site can use the fill for 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#
About 8% of men have a red-green deficiency. To a deuteranope, a red and a green at the same lightness collapse into the same gray.
Color is never the only signal for a value's sign. Anywhere in this system.
Every signed value also carries a cue that isn't color:
| 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 |
The test: switch the palette to grayscale and confirm every sign still reads. That's a ship gate, listed in the quality bar.
Color is never the only signal
The Signal theme (accessibility variant)#
An opt-in mapping for users who need it, plus support for the system-level prefers-contrast setting. Blue and amber are the standard colorblind-safe diverging pair. They differ 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);
}
"Green means up" is a strong enough convention that this isn't the default. It's a setting in preferences next to density, and every chart and every number must honor it.
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" // required, no default
// standard → up is positive (revenue, cash, margin)
// inverted → up is negative (burn, COGS, churn, DSO)
// neutral → no color at all (headcount, invoice count)
Default every metric to standard and your dashboard will show a cost overrun in green.
Data-visualization palette#
Categorical series have to be distinguishable from each other and from the semantic pair, so they use 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. Group the rest into "Other." More series 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 other sign cue.
- 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.
- No decorative color on a data surface. No colored card headers, tinted table stripes or category-colored borders on every row. Save color for the exception.
- Backgrounds are tints, not fills. Status pills use
*-subtlebackgrounds with*-colored text, not saturated fills with white text. Saturated fills are for the one primary action. - No gradients on buttons, cards or charts. One exception: an area chart's fill may fade a single hue to transparent. That keeps the line readable over gridlines, so it's there for legibility, not decoration.
- No glow as affordance. A glow is never what tells you something is interactive or focused.
- Borders carry the structure, not color. See elevation.
- Every token is defined for both modes when it's declared. No token works only 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 deliberately stricter than WCAG. 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 exception inside a figure is the cents of a figure-hero / figure-lg display value: those glyphs are large text, and the integer part carries the amount at full contrast (typography). In tables, cents stay at full color.
Theming for consumers#
The system ships one point of view. You can 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 for theming. Change them and you've forked the system. That's fine, since you own the code, but keeping it coherent is then up to you.