Modes
Light is the default. Dark is for late-night reading on a phone, an archive scrolled at midnight, a long interview opened after the kitchen is shut. Same brand, same words, same photographs – translated for a darker room.
Components never know which mode they’re in. They bind to semantic names – --es-color-surface-page, --es-color-text-primary, --es-color-border-subtle – and the mode swap rebinds those names in a single block at [data-theme="dark"]. The primitive ramps stay constant. Only the bindings flip.
| Default | Light |
| Switch | data-theme on <html> |
| Storage | localStorage['es-theme'] with system fallback |
| Contract | Semantic tokens only – never primitives |
| Contrast | WCAG 2.2 AA in both modes |
Mapping
Four families flip between modes: surface, content, border, feedback. Each row below names a semantic token, the primitive it resolves to in light, and the primitive it resolves to in dark. Components reach for the left column. The right two columns are the system’s job.
Mode bootstrap
Set data-theme on <html> before paint, or the page flashes light, then dark, on every refresh. The script is small enough to inline in the <head> – it reads a saved preference, falls back to the OS setting, and sets the attribute synchronously.
<!-- In <head>, before any stylesheet links -->
<script>
(function () {
var saved = localStorage.getItem('es-theme');
var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', saved || sys);
})();
</script>
<link rel="stylesheet" href="/styleguide/tokens/color.css" />
That’s the whole bootstrap. Ten lines, no framework, no FOUC. The toggle UI elsewhere on the page writes to localStorage['es-theme'] ('light', 'dark', or removes the key for “system”).
function setTheme(value) {
if (value === 'system') {
localStorage.removeItem('es-theme');
var sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', sys);
} else {
localStorage.setItem('es-theme', value);
document.documentElement.setAttribute('data-theme', value);
}
}
Token bindings
The light bindings already live at :root in color.css. Dark is one additional block, scoped to [data-theme="dark"]. Primitives (--es-color-teal-600, --es-color-navy-900, the ramps) are not redeclared – only semantic names rebind.
/* Light is the default — already shipped in color.css */
:root {
--es-color-surface-page: var(--es-color-paper);
--es-color-surface-raised: var(--es-color-white);
--es-color-surface-sunken: var(--es-color-cream);
--es-color-surface-muted: var(--es-color-stone-100);
--es-color-surface-inverse: var(--es-color-navy-900);
--es-color-text-primary: var(--es-color-ink);
--es-color-text-secondary: var(--es-color-stone-700);
--es-color-text-tertiary: var(--es-color-stone-600);
--es-color-text-onInverse: var(--es-color-paper);
--es-color-text-link: var(--es-color-teal-700);
--es-color-border-subtle: var(--es-color-stone-200);
--es-color-border-default: var(--es-color-stone-300);
--es-color-border-strong: var(--es-color-stone-500);
--es-color-border-focus: var(--es-color-teal-600);
--es-color-feedback-success-surface: var(--es-color-sage-100);
--es-color-feedback-success-text: var(--es-color-sage-900);
--es-color-feedback-warning-surface: var(--es-color-mustard-100);
--es-color-feedback-warning-text: var(--es-color-mustard-900);
--es-color-feedback-danger-surface: var(--es-color-red-100);
--es-color-feedback-danger-text: var(--es-color-red-900);
--es-color-feedback-info-surface: var(--es-color-sky-100);
--es-color-feedback-info-text: var(--es-color-navy-900);
}
/* Dark — one block, semantic rebindings only */
[data-theme='dark'] {
--es-color-surface-page: var(--es-color-navy-950);
--es-color-surface-raised: var(--es-color-navy-900);
--es-color-surface-sunken: var(--es-color-navy-800);
--es-color-surface-muted: var(--es-color-navy-700);
--es-color-surface-inverse: var(--es-color-teal-600);
--es-color-text-primary: var(--es-color-paper);
--es-color-text-secondary: var(--es-color-stone-300);
--es-color-text-tertiary: var(--es-color-stone-500);
--es-color-text-onInverse: var(--es-color-paper);
--es-color-text-link: var(--es-color-teal-400);
--es-color-border-subtle: var(--es-color-navy-800);
--es-color-border-default: var(--es-color-navy-700);
--es-color-border-strong: var(--es-color-stone-400);
--es-color-border-focus: var(--es-color-teal-500);
--es-color-feedback-success-surface: var(--es-color-sage-800);
--es-color-feedback-success-text: var(--es-color-sage-100);
--es-color-feedback-warning-surface: var(--es-color-mustard-800);
--es-color-feedback-warning-text: var(--es-color-mustard-100);
--es-color-feedback-danger-surface: var(--es-color-red-900);
--es-color-feedback-danger-text: var(--es-color-red-100);
--es-color-feedback-info-surface: var(--es-color-sky-800);
--es-color-feedback-info-text: var(--es-color-sky-100);
}
Surface ladder
Four surfaces, side by side. The same hierarchy reads in both modes – page sits behind, muted is the gentle inset, raised lifts a card off the page, sunken pushes a panel below it.
Content ladder
Three weights of text – primary for the headline, secondary for the supporting line, tertiary for the timestamp. Inverse sits on the surface-inverse fill; it stays paper-coloured in both modes because the surface itself flips.
Feedback in dark mode
Each status keeps its hue – success is sage, danger is red, warning is mustard, info is sky – but the surface drops to the 800/900 stop and the text rises to the 100 stop. Saturation never changes. The status reads as the same status, only translated.
Mode toggle UI
Three options. System is the default; it follows the OS preference and tracks with the reader’s environment – light at the desk, dark on the train home. Light and Dark are manual overrides for readers who want the same mode everywhere.
The toggle writes to localStorage ('light' or 'dark') or removes the key (System). The bootstrap script reads it on the next load. Live mode flips happen by re-setting data-theme on <html>; no reload needed because every component already reads from CSS variables.
Editorial photography in dark
Image treatment doesn’t change. The same photographs, the same crops, the same warm-grey duotones we publish in print. Dark mode flips the chrome around the image – the page, the captions, the rules – not the image itself. A photograph that’s been graded for print should look the same on either canvas.
The single concession: a 1px hairline at --es-color-border-subtle around full-bleed images in dark stops them dissolving into the navy page. That’s it.
Usage rules
.card {
background: var(--es-color-surface-raised);
color: var(--es-color-text-primary);
border: 1px solid var(--es-color-border-subtle);
}Names describe role, not colour. The mode swap rebinds them; the component never knows.
.card {
background: #FFFFFF;
color: var(--es-color-navy-900);
border: 1px solid var(--es-color-stone-200);
}Locks the card to light mode. In dark, it renders white-on-navy and breaks the moment the page loads.
navy-950 → page
navy-900 → raised
navy-800 → sunken
navy-700 → muted
Dark surfaces resolve to the navy ramp we already publish. No new primitives.
—es-color-surface-page-dark:
#0F1622;
A dark-only hex is a second palette. Two palettes, two brands, two contrast bills to pay.
The brand teal stays at full saturation in both modes. Same colour, same weight, same recall.
Boosting hue in dark mode is how a publication starts looking like a gaming app at midnight.
The dark canvas is #101622 – ink-leaning navy, not pure black. It softens the contrast and matches the warmth of paper.
#000000 makes white text vibrate, kills photographic mid-tones, and reads as terminal, not editorial.
ink on paper · 18.7:1
paper on navy-950 · 16.4:1
teal-700 on paper · 5.8:1
teal-400 on navy-950 · 6.1:1
Every semantic pair clears AA (4.5:1 for body, 3:1 for large text) in both modes. Audited as part of the token build.
stone-500 on navy-900 · 3.2:1
teal-600 on navy-950 · 3.9:1
“looks fine” · ✕
Dark contrast lies to the eye. Assume nothing; run the numbers, fail loudly, and rebind.
Tokens to bind
Every semantic token below has a light binding and a dark binding. Primitives stay constant; only these names rebind under [data-theme="dark"].
Surface (5)
- —es-color-surface-page
- —es-color-surface-raised
- —es-color-surface-sunken
- —es-color-surface-muted
- —es-color-surface-inverse
Content (5)
- —es-color-text-primary
- —es-color-text-secondary
- —es-color-text-tertiary
- —es-color-text-link
- —es-color-text-linkHover
Border (4)
- —es-color-border-subtle
- —es-color-border-default
- —es-color-border-strong
- —es-color-border-focus
Feedback (12)
- —es-color-feedback-success-{surface,border,text}
- —es-color-feedback-warning-{surface,border,text}
- —es-color-feedback-danger-{surface,border,text}
- —es-color-feedback-info-{surface,border,text}
Twenty-six semantic tokens bind across modes. Every component in the system reaches for one of these names and nothing else.