Docs
Dark Mode Engine

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

AxisValuesWhat it changes
mode'light' · 'dark' · 'system'The polarity: the canonical seed swap (baseLight↔baseDark, deepLight↔deepDark) and the surface ladder
a11ynull · '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.

GroupRolesLight (stock)Dark (stock)
surfacecanvas · sunken · paper · raised · overlay · scrim#f9f9f7#ffffff#0b0b0b#181818#262626
inkprimary · secondary · muted · disabled#0b0b0b · #363636 · #5e5e5e#eeeeec · #b5b5b4 · #9b9b9a
edgehairline · border · strongrgba(11, 11, 11, 0.1 / 0.2 / 0.32)rgba(238, 238, 236, 0.1 / 0.2 / 0.32)
washhover · pressed · selectedink @ 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 canvas
dark
paper still lighter than canvas

Contrast 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

NeedReach for
Pin a ladder value (reference design)createTheme({ colors: { ladder: { dark: { surface: { paper: '#1a1a19' } } } } }) — per polarity
A non-canonical dark themelightTheme/darkTheme pair (wins over colors)
Literal styling, no bridgea single theme prop
No <html> writessyncDocument={false} (then set color-scheme yourself)
A custom attributeattribute="data-theme" (the pre-paint script takes the same option)
Not the page's ground<Ground bg={…}> or any bgColor container

See also