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>
<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.
<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.
.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.
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.
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 anyrgba(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.