Add a dark mode switch

Dark mode is already on. Deck follows the operating system with no configuration, so the job on this page is narrower than you expected: add a control a person can press to override their OS, make that choice survive a reload, and know the three places where the automatic answer is not quite right yet.

The switch

One attribute on a button. No handler, no state, no code.

<button class="btn btn-icon btn-ghost" data-deck-theme aria-label="Switch theme">
  <svg class="icon"><use href="/assets/deck/deck-icons.svg#moon"></use></svg>
</button>
Both of these switch this page. Press one
<button class="btn btn-icon btn-ghost" data-deck-theme aria-label="Switch theme"> <svg class="icon"><use href="../../assets/deck/deck-icons.svg#moon"></use></svg> </button> <button class="btn btn-sm" data-deck-theme>With a label instead</button>
<button class="btn btn-icon btn-ghost" data-deck-theme aria-label="Switch theme">
  <svg class="icon"><use href="../../assets/deck/deck-icons.svg#moon"></use></svg>
</button>
<button class="btn btn-sm" data-deck-theme>With a label instead</button>

That writes data-theme="dark" or data-theme="light" on the <html> element and saves it to localStorage under deck-theme. On the next load the saved value is applied before Deck.init() runs, so there is no flash of the wrong theme.

From your own code, Deck.theme() is the same thing:

Deck.theme();          // 'auto', 'light' or 'dark'
Deck.theme('dark');    // set it
Deck.theme('light');

Three states, not two. 'auto' means no attribute is set and the OS is deciding — which is the state a page starts in and the one a good switch should be able to return to. A two-way toggle quietly takes that option away from someone whose machine already switches at sunset.

Three states. Auto is the one people actually want
<div class="segmented" role="group" aria-label="Colour theme"> <button class="btn btn-sm" onclick="document.documentElement.removeAttribute('data-theme')">Auto</button> <button class="btn btn-sm" onclick="Deck.theme('light')">Light</button> <button class="btn btn-sm" onclick="Deck.theme('dark')">Dark</button> </div>
<div class="segmented" role="group" aria-label="Colour theme">
  <button class="btn btn-sm" onclick="document.documentElement.removeAttribute('data-theme')">Auto</button>
  <button class="btn btn-sm" onclick="Deck.theme('light')">Light</button>
  <button class="btn btn-sm" onclick="Deck.theme('dark')">Dark</button>
</div>

Why it works without a second stylesheet

Deck declares its semantic tokens with light-dark(), so each one carries both values and the browser picks based on color-scheme:

:root { color-scheme: light dark; }

--bg:      light-dark(var(--ink-25),  var(--ink-1000));
--surface: light-dark(var(--ink-0),   var(--ink-950));
--text:    light-dark(var(--ink-900), var(--ink-100));
--brand:   light-dark(var(--brand-600), var(--brand-400));

[data-theme="light"] { color-scheme: light; }
[data-theme="dark"]  { color-scheme: dark; }

There is no @media (prefers-color-scheme: dark) block redefining forty colours, and no .dark class threaded through every rule. There are 16 role tokens, each holding a pair, and switching color-scheme switches all of them at once.

color-scheme also tells the browser to render its own furniture darkly: form controls, scrollbars, the spellcheck underline and the space outside the page. That is the part hand-rolled dark modes usually miss, and it is why a page with dark CSS and light scrollbars looks wrong in a way that is hard to place.

Making your own CSS follow

One rule: reference the roles, not the ramps.

Hard-codes white into the page

@layer app.components {
  .invoice {
    background: var(--ink-0);
    color: var(--ink-900);
    border: 1px solid var(--ink-200);
  }
}

Follows the theme for free

@layer app.components {
  .invoice {
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--line);
  }
}

The ramps — --ink-*, --brand-*, --good-* — are fixed colours and are the same in both themes by design. They are the palette the roles are mixed from. The 16 roles are listed on the token reference; if you only learn four, learn --bg, --surface, --text and --line.

For a value with no matching role, use light-dark() yourself. It is a plain CSS function and it works in your layer exactly as it does in Deck's:

.chart-gridline { stroke: light-dark(oklch(90% .01 232), oklch(30% .01 232)); }

Rendering the choice server-side

localStorage is read by a script, which means it is read after the HTML has arrived. Deck applies it before first paint so you will not usually see a flash, but the robust version is to store the preference on the user and render it:

<html <?= Deck::htmlAttributes(lang: 'en') ?> <?= Deck::theme(mode: $user->theme) ?>>
<!-- <html lang="en" data-theme="dark"> -->

Pass null for a user who has not chosen, and no attribute is emitted — so the OS decides, which is the correct default. Never emit data-theme="light" as a default: that overrides the preference of everyone who set their machine to dark, which is the opposite of what a default should do.

Three things that are not automatic yet

Measured on a real page rather than assumed. None of these will stop you shipping, and all three are the kind of thing you would otherwise find at the wrong moment.

1. Eight status surfaces stay light

.alert-good, .alert-warn, .alert-bad, .badge-good, .badge-warn, .badge-bad, .icon-tile-good and .icon-tile-bad all fill with the 100 step of their ramp, which is a fixed pale tint with no light-dark() around it. On a dark page they read as light stickers. They are still legible — the text is the fixed dark 700 step, so contrast measures between 5.3:1 and 6.9:1 — but they do not match the page. .alert-info and .badge-brand are fine, because those use --brand-soft, which is a role.

Until that is fixed in the framework, this is the patch:

@layer app.base {
  :root {
    /* the fill */
    --good-100: light-dark(oklch(94%   .04 var(--hue-good)), oklch(26% .05 var(--hue-good)));
    --warn-100: light-dark(oklch(95%   .04 var(--hue-warn)), oklch(26% .05 var(--hue-warn)));
    --bad-100:  light-dark(oklch(94.5% .04 var(--hue-bad)),  oklch(26% .05 var(--hue-bad)));

    /* and the text on it, or you get dark on dark */
    --good-700: light-dark(oklch(43% .12 var(--hue-good)), oklch(86% .10 var(--hue-good)));
    --warn-700: light-dark(oklch(43% .12 var(--hue-warn)), oklch(86% .10 var(--hue-warn)));
    --bad-700:  light-dark(oklch(43% .12 var(--hue-bad)),  oklch(86% .10 var(--hue-bad)));
  }
}

Override the fill and its text together, or you get dark text on a dark fill — readable in neither theme. Keeping var(--hue-*) in there means the patch still follows your brand hues rather than pinning the status colours to Deck's.

2. Shadows follow the OS, not your switch

Dark mode wants heavier shadows, because a soft grey shadow is invisible on a near black surface. Deck adjusts --shadow-2 through --shadow-4 for that — but it does it inside @media (prefers-color-scheme: dark), which only responds to the operating system. Force dark with the attribute on a light machine and you get a dark page with light-mode shadows; force light on a dark machine and you get heavy shadows on white.

@layer app.base {
  [data-theme="dark"] {
    --shadow-2: 0 2px 4px oklch(0% 0 0 / .3), 0 4px 12px oklch(0% 0 0 / .34);
    --shadow-3: 0 4px 10px oklch(0% 0 0 / .34), 0 14px 30px oklch(0% 0 0 / .4);
    --shadow-4: 0 10px 20px oklch(0% 0 0 / .4), 0 28px 60px oklch(0% 0 0 / .5);
    --shadow-inset: inset 0 1px 0 oklch(100% 0 0 / .06);
  }
}

If your switch only ever offers "follow the system", neither of these applies to you. That is a legitimate product decision and it is the cheapest correct dark mode there is.

3. Images and embeds do not have a dark version

A screenshot with a white background, a logo drawn in black, a third-party iframe. None of these know about your theme. Deck cannot help — but <picture> can, and it needs no JavaScript.

<picture>
  <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/logo-light.svg" alt="Ledgerly">
</picture>

Testing it

  • In Chrome dev tools: the three-dot menu, More tools, Rendering, then Emulate prefers-color-scheme. Firefox has a light/dark toggle directly in the Inspector.
  • Test both paths. The OS preference and the forced attribute take different code paths through Deck, as caveat two shows, so a page that is right one way can be wrong the other.
  • Look for anything that stayed the same. A surface that did not move between the two screenshots is a hard-coded colour, and it is almost always a ramp token used where a role belonged.
  • Check your own box-shadows and any rgba(0,0,0,…) you have written. Black at 8% opacity is a shadow on white and nothing at all on black.

Next

Changing your brand colour uses the same token system and the two compose — a purple tenant gets a correct dark mode without anyone choosing a second purple. The token reference lists all 144 with a live preview of each.