Dark Mode Engine
Dark mode — and the accessibility color modes — are an engine concern.
You declare the mode you want; the Design System owns the surfaces, the
elevation, the contrast, the <html> attributes, the persistence and the
server-rendered first paint.
import { ApollionProvider } from '@apollion-dsi/core/themes';
<ApollionProvider colors={seeds} defaultMode="system" persist="localStorage">
<App />
</ApollionProvider>;That is the whole consumer contract. Everything below is what the engine does for you — read it to understand, not to re-implement.
Two independent axes
| Axis | Values | What it changes |
|---|---|---|
| mode | 'light' · 'dark' · 'system' | The polarity: the canonical seed swap (baseLight↔baseDark, deepLight↔deepDark) and the surface ladder |
| a11y | null · 'grayscale' · 'high-contrast' · 'colorblind' | The seeds: the preset remaps brand/feedback colors before the engine derives light + dark |
They compose freely — dark + colorblind or light + high-contrast are
first-class combinations, never a single "theme" enum. The provider exposes
both from useApollionTheme():
const { mode, resolvedMode, setMode, a11y, setA11y } = useApollionTheme();
<Button text={`Mode: ${resolvedMode}`} onClick={() => setMode(resolvedMode === 'dark' ? 'light' : 'dark')} />;
<Button text="Colorblind-safe" onClick={() => setA11y('colorblind')} />;mode is the preference ('system' stays 'system'); resolvedMode is
what renders.
The surface ladder
A dark theme is not an inverted light theme. The engine derives, from the contrast anchors alone and in OKLch, a ladder of grounds whose elevation reads the same way in both polarities — paper is lighter than the canvas in light and in dark (tonal elevation), edges and washes are the primary ink at a fixed alpha (they work on any ground), and two halation floors keep the dark canvas off pure black and the dark ink off pure white.
| Group | Roles | Light (stock) | Dark (stock) |
|---|---|---|---|
surface | canvas · sunken · paper · raised · overlay · scrim | #f9f9f7 → #ffffff | #0b0b0b → #181818 → #262626 |
ink | primary · secondary · muted · disabled | #0b0b0b · #363636 · #5e5e5e | #eeeeec · #b5b5b4 · #9b9b9a |
edge | hairline · border · strong | rgba(11, 11, 11, 0.1 / 0.2 / 0.32) | rgba(238, 238, 236, 0.1 / 0.2 / 0.32) |
wash | hover · pressed · selected | ink @ 5 / 10 / 8 % | ink @ 7.5 / 14 / 12 % |
Every ink clears WCAG AA and the APCA tiers (|Lc| ≥ 75 / 60 / 45) over
the paper — WCAG 2 alone under-weights light-on-dark pairs, so the dark ladder
is gated by both. The roles are ordinary tokens:
<Flex bgColor="surface.paper" borderColor="edge.hairline" borderWidth="thin">
<Text color="ink.primary">Title</Text>
<Text color="ink.secondary">Supporting copy</Text>
</Flex>Components already sit on the ladder (Paper, Card, Modal, inputs,
tables, tooltips, …). The full cheatsheet lives in Storybook under
Themes / Surface ladder.
light
paper over canvasdark
paper still lighter than canvasContrast against the real ground
Every painted ground (bgColor, the page canvas, <Ground>) publishes a set
of CSS custom properties — --apollion-on-ground-* — computed in JS against
that ground's literal, in both modes. Inks that are legible by definition
(ink.*, palette accents used as text, the text side of the neutral ramp)
read the nearest painted ancestor through the cascade, so a Text inside a
Flex bgColor="main" resolves its ink against main, not against the page.
<Flex bgColor="main">
<Text color="primary">Readable over the brand ground — no prop needed</Text>
</Flex>For a ground the Design System did not paint (a consumer styled.div, a
photo), declare it once:
import { Ground } from '@apollion-dsi/core/themes/ground';
<Ground bg="#0b2a3a" paint={false} style={{ background: 'url(hero.jpg)' }}>
<Text>Readable over the photo's average tone</Text>
</Ground>;How the flip works — no re-render
Every color in the stylesheet is a light-dark(<light>, <dark>) pair and the
mode is decided by color-scheme alone, written on <html> by the provider
(data-apollion-mode + style.colorScheme). A mode change is an attribute
change: identical class names in both modes, zero React work, byte-identical
server output. useApollionTheme().theme hands JS consumers the literal of
the resolved mode (theme.modes.{light,dark} carry both tables).
Browser floor: light-dark() is Baseline 2024 (Chrome/Edge 123, Firefox 120,
Safari 17.5). Older engines render the light side; for them, link the tokens
CSS surface (output.cssModes) or pass a single pre-built theme.
Persistence and SSR — no flash
persist="localStorage" or persist="cookie" stores the preference (so
'system' keeps following the OS) under storageKey (apollion-mode; the
a11y preset under apollion-mode-a11y). Pair it with the pre-paint script so
the attributes land before the first paint:
// Next.js App Router — app/layout.tsx
import { ApollionModeScript, readA11yFromCookie, readModeFromCookie } from '@apollion-dsi/core/themes/mode';
import { headers } from 'next/headers';
export default function RootLayout({ children }) {
const cookie = headers().get('cookie');
const preference = readModeFromCookie(cookie) ?? 'system';
return (
<html lang="en">
<head>
<ApollionModeScript persist="cookie" defaultMode="system" />
</head>
<body>
<ApollionProvider
colors={seeds}
defaultMode={preference}
defaultA11y={readA11yFromCookie(cookie)}
initialMode={preference === 'dark' ? 'dark' : 'light'}
persist="cookie"
>
{children}
</ApollionProvider>
</body>
</html>
);
}// Next.js Pages Router — pages/_document.tsx
import { getModeScript } from '@apollion-dsi/core/themes/mode';
<Head>
<script dangerouslySetInnerHTML={{ __html: getModeScript({ persist: 'localStorage', defaultMode: 'system' }) }} />
</Head>;initialMode is the SSR contract: server and first client render agree on
it, then the persisted/system preference is applied in a layout effect —
before paint, after hydration — so React never sees a mismatch. With
localStorage the ground and every pair flip pre-paint through the script;
with cookie the server already knows the mode.
Static sites and non-React pages link the tokens surface instead: one
css/<brand>.<surface>.<dimension>.modes.css file carries both modes scoped by
the same attribute (output.cssModes, see
Tokens output formats).
Escape hatches
| Need | Reach for |
|---|---|
| Pin a ladder value (reference design) | createTheme({ colors: { ladder: { dark: { surface: { paper: '#1a1a19' } } } } }) — per polarity |
| A non-canonical dark theme | lightTheme/darkTheme pair (wins over colors) |
| Literal styling, no bridge | a single theme prop |
No <html> writes | syncDocument={false} (then set color-scheme yourself) |
| A custom attribute | attribute="data-theme" (the pre-paint script takes the same option) |
| Not the page's ground | <Ground bg={…}> or any bgColor container |
See also
- ApollionProvider — the full prop table.
- Surface Inversion — negative surfaces are the opposite polarity's ladder.
- Server-Side Rendering — determinism and the tokens CSS surface.
- Accessibility Presets — the
a11yaxis presets.