This article is published in English.
Nine Dark Mode Techniques Compared, From filter Hacks to Server Cookies
Compare nine ways to add dark mode to a web app, from invert filters to tokens, light-dark() and server cookies, and learn which bugs each one quietly introduces.
Dark mode is usually presented as a choice between a quick hack and the proper solution, but production sites use at least nine distinct techniques, and each of them fixes only part of the problem. The parts a technique ignores tend to surface later as flickering pages, broken fixed headers or colours that silently refuse to change. This guide ranks those nine approaches, explains what each one gets right and wrong, and then covers the details that trip up even careful implementations: the flash of the wrong theme, the color-scheme property, three-state preferences, transitions, embedded content and palette design.
The behavioural claims below are grounded in primary material: CSS Working Group drafts, the WHATWG HTML Standard, the machine-readable browser-compat-data behind MDN, Baseline status data and the source code of the next-themes library, cross-checked against headless Chromium. Two popular beliefs do not hold up under that scrutiny, and one behaviour, filter capturing position: fixed descendants, turns out to be a far better reason to avoid the invert trick than the vague performance argument usually offered.
Dark mode is three separate problems
"Add a dark theme" sounds like one ticket. In practice it bundles three questions that are independent enough for you to answer one perfectly and still ship something broken:
- Which theme should be shown? The operating system has a preference, the user may want to override it, and they also need a way to go back to following the system. A simple on/off toggle loses that third option for good.
- How do the colours change? A single switch has to update every surface, border, icon and shadow, and its location effectively defines your CSS architecture.
- When is the theme applied? If the decision arrives after the first paint, users watch the page change colour in front of them.
Every technique below answers some subset of these. The pattern is revealing: the so-called lazy approaches typically handle the second question in isolation and ignore the first and third entirely.
How to read the ranking
The top three entries are not rivals. They combine into a single setup: semantic tokens are the base, light-dark() is a more compact way to write those tokens, and a server-read cookie is how you deliver the chosen theme without a flash. Ranks four to six are genuine trade-offs where you pick one. Ranks seven to nine are technical debt.
Rank 9: inverting the whole page with filter
The shortest possible dark mode applies an inversion and a hue rotation to the root element:
html {
filter: invert(1) hue-rotate(180deg);
}
It immediately forces a second rule that re-inverts every piece of media so photos and videos look normal again:
/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
filter: invert(1) hue-rotate(180deg);
}
The objection people usually raise is performance, which is hard to demonstrate cleanly. There is a much stronger one. MDN's documentation on containing blocks spells it out: a filter set to anything other than none turns the element into the containing block for descendants with position: fixed and position: absolute. It also starts a new stacking context, which quietly changes how every z-index beneath it resolves.
A headless test makes this concrete. Place a fixed bar inside a filtered wrapper on a 3000px-tall page in Chromium 141, scroll down 400px and measure where the bar sits:
await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.
The bar reports a viewport offset of 100 instead of 0, so it is no longer fixed; it scrolls away with the content. Applied to html, the filter therefore breaks every sticky header, fixed navigation, modal overlay, toast stack and drawer on the page. That is a correctness defect you can reproduce in a few lines, not a matter of taste.
The remaining problems are familiar:
- Every raster asset needs a counter-inversion, and logos with baked-in brand colours still come out wrong, because rotating the hue by 180 degrees is not an accurate inverse in any colour space.
- Brand colours turn into their mathematical opposites rather than a designed dark palette.
- No authoritative guidance recommends it. web.dev's dark-mode guidance never suggests inverting whole pages; for media it proposes
filter: grayscale(50%)on photographs andinvert(100%)only for monochrome icons. - Chrome's own advice about its automatic inversion is to build a curated dark theme instead of opting out.
Ranks 8 and 7: approaches that work until they do not
Per-component overrides
Writing a dark variant for each component is the natural first attempt. It is not so much wrong as unbounded. You start with light styles:
.card { background: #fff; color: #14161a; }
.card .btn { background: #f0f2f5; }
and then add dark counterparts under a body class:
body.dark .card { background: #121212; color: #fff; }
body.dark .card .btn { background: #333; }
body.dark .card .btn:hover { background: #444; }
Because the dark rules must beat the light ones, selectors keep getting more specific; body.dark .card .btn:hover already has four parts on the first day. Hex values drift as well, with #121212 in one file, #111 in another and #0f0f0f in a copied component, and there is nowhere central to check contrast automatically. The core scaling problem fits in one line: overrides grow with the number of components, while tokens grow with the number of roles, and most design systems settle at roughly 12 to 20 roles.
Two separate stylesheets
Loading a light and a dark stylesheet behind media attributes looks efficient:
<link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)">
It does not avoid the second download, which is where people go wrong. As the web.dev article on prefers-color-scheme explains, the stylesheet whose media query does not match is still fetched, only at the lowest priority so it cannot compete with resources the page currently needs. The benefit is a shorter critical path, not fewer bytes. On top of that, the media attribute only reads the OS setting, so no manual toggle can ever influence it; the two files tend to drift apart over time; and bundlers can mishandle the pair, as a Vite issue documents.
Rank 6: a pure-CSS toggle with :has()
A visually hidden checkbox and its label can act as the switch:
<input type="checkbox" id="theme" class="sr-only">
<label for="theme">Dark mode</label>
The root then reacts to the checkbox state through :has():
html:has(#theme:checked) {
color-scheme: dark;
--bg-surface: #1b1f27;
--text-1: #e8e6e3;
--border: #2b313c;
}
This really does need no JavaScript, and :has() reached Baseline "widely available" on 2026-06-19. Two points matter. First, flip the colour tokens as well as color-scheme, as the example does. Second, the state lives only in the DOM, so each page load starts from scratch and nothing is saved. It also cannot represent three states neatly; that would take radio buttons and more selector branches. It is a good fit for a demo, a CodePen or a single-page document, but not for a product.
Rank 5: Tailwind's dark: variant
The dark: variant is not wrong, it just puts colour decisions in the wrong place: in the templates, repeated at every call site.
<div class="bg-white dark:bg-zinc-900
text-zinc-900 dark:text-zinc-100
border-zinc-200 dark:border-zinc-800
hover:bg-zinc-50 dark:hover:bg-zinc-800">
The result is long class strings that drift, contrast that no script can audit because the dark values are scattered through templates, and an additional theme such as high contrast or a brand skin that multiplies markup instead of adding one token layer. It also does nothing for browser-drawn UI and still needs a separate blocking script to avoid a flash.
The fix lives inside Tailwind itself. Map theme colours to CSS variables and switch the variables once; then you write bg-surface without any dark: prefix. Start with the standard import:
@import "tailwindcss";
Then define a custom variant, point theme colours at variables and give each theme its own variable values:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
--color-surface: var(--surface);
--color-content: var(--content);
}:root { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
[data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
[data-theme="hc"] { --surface: #000000; --content: #ffffff; color-scheme: dark; }
The :where() wrapper is intentional. It gives the dark variant zero extra specificity, so dark: utilities never accidentally override unrelated styles. Adding the high-contrast theme costs one line rather than a pass through every template.
Rank 4: following prefers-color-scheme only
The simplest robust option is to define light tokens by default and redefine them inside a media query:
:root {
color-scheme: light;
--bg-base: #ffffff; --text-1: #14161a;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg-base: #12141a; --text-1: #e8e6e3;
}
}
There is no JavaScript, no flash and nothing to hydrate. This is the fastest approach on the entire list, and its single drawback is decisive for applications and irrelevant for content: users cannot override the system setting.
If nobody has ever asked for a toggle, as is common for blogs, documentation, changelogs and marketing pages, stop here. Nothing further down the list beats it for that kind of site.
Two specification details are worth knowing:
- Media Queries Level 5 warns that the feature may gain more values in future, sepia being the example given, and recommends testing by negation:
(prefers-color-scheme: dark)versus(not (prefers-color-scheme: dark)), rather than matchinglightexplicitly. - The
no-preferencevalue has been removed. It is absent from the current spec and no browser implements it; a user without a preference matcheslight.
Rank 3: light-dark() and its silent failure mode
light-dark() roughly halves the size of a token file, because a single declaration holds both values:
:root { color-scheme: light dark; } /* REQUIRED */
.card {
background: #fff; /* fallback for old browsers */
background: light-dark(#fff, #1b1f27);
color: light-dark(#14161a, #e8e6e3);
border-color: light-dark(#e2e5ea, #2b313c);
}
/* a manual override becomes ONE property write */
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
That is a complete theme system without any @media block or second :root rule. A manual override shrinks to setting color-scheme on the root.
The catch costs teams hours. Running several variants through Chromium 141 with the colour scheme forced both ways produces three important findings:
- Without
color-scheme,light-dark()does nothing. On a dark system it quietly hands back the light colour, and nothing in the console hints at the problem. This is the most commonlight-dark()bug, and it stays invisible until someone on a dark system reports it. - Declaring
color-scheme: darkon an element makes it pick the second argument, whatever the OS says. That is why a manual toggle is a single property write rather than a class swap plus a parallel set of rules. - Setting
color-scheme: darkon an element other than the root does not give it a dark background. It changes system colours and native controls, but the canvas background only follows the root. This is the most widespread misunderstanding of the property.
As a sanity check for your own palette, in dark mode Chromium computes the Canvas system colour as rgb(18, 18, 18), which is #121212, the same base surface Material suggests for dark themes.
Before relying on it, consider these caveats:
- It is Baseline "newly available" rather than "widely available": Chrome and Edge 123, Firefox 120 and Safari 17.5, with the newly-available date of 2024-05-13 putting the widely-available threshold around 2026-11-13.
- It does not degrade gracefully. Browsers without support discard the entire declaration as invalid, so always put a plain fallback on the same property first, as the example does.
- It is a colour value, so it cannot be used as a media query condition. Using it inside declarations within an
@mediablock is fine; that is a different thing. - Image arguments, as in
light-dark(url(a.png), url(b.png)), arrived only in Chrome 150, Firefox 150 and Safari 27 according to the compatibility data at the time of writing, which is too recent to depend on.
One documentation warning: MDN's prose page for light-dark() has listed Chrome 119 and Safari 17.2, while MDN's own browser-compat-data and the Baseline API give 123 and 17.5. When the two disagree, trust the structured data, and check the current pages before quoting versions.
Rank 2: semantic tokens, the layer everything else needs
The key rule is to name the role a colour plays, never the colour itself. Begin with primitives, the raw palette values that components never reference directly:
/* primitives: raw values, never consumed by components */
:root {
--gray-0: #ffffff; --gray-50: #f4f5f7; --gray-200: #e2e5ea;
--gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
--blue-500: #3b82f6; --blue-400: #60a5fa;
}
On top of them sits a semantic layer. Light values are the default, a single [data-theme="dark"] block replaces them all, and components only ever consume role names, so they never need to know which theme is active:
/* semantic roles: light is the default */
:root {
color-scheme: light;
--bg-base: var(--gray-0);
--bg-surface: var(--gray-50);
--text-1: var(--gray-900);
--text-2: var(--gray-600);
--border: var(--gray-200);
--accent: var(--blue-500);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08);
}/* one block flips the whole app */
[data-theme="dark"] {
color-scheme: dark;
--bg-base: #12141a; /* grey, not #000 */
--bg-surface: #1b1f27; /* lighter = higher up */
--bg-raised: #232833; /* lighter still */
--text-1: #e8e6e3;
--text-2: #a2acbb;
--border: #2b313c;
--accent: var(--blue-400);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.5);
}/* components never know which theme is active */
.card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
Notice the details in the dark block: the base is dark grey rather than black, surfaces get lighter as they sit higher, the accent shifts to a lighter step, and the shadow becomes stronger so it stays visible.
A simple test tells you whether a token name is good: can you describe when to use it without mentioning a colour? "Background of an elevated panel" describes a role; "light grey" describes a swatch. Only roles survive a theme switch, since a token literally called light grey should never become dark.
For the switch itself, a data-theme attribute beats a .dark class. It holds three or more values naturally, it cannot collide with utility classes, and changing it is one assignment to document.documentElement.dataset.theme. For more on driving custom properties at runtime, see the blog's guide to theming with CSS custom properties.
Rank 1: reading a theme cookie on the server
Rendering the theme on the server is the only option that avoids all four of the usual costs: an inline script, a flash, a hydration mismatch and a Content Security Policy exception, because the server knows the theme before it sends the first byte. In a Next.js App Router project, the root layout imports the cookie helper:
// app/layout.tsx
import { cookies } from 'next/headers';
and writes the stored theme straight onto the html element, falling back to light:
export default async function RootLayout({ children }) {
const store = await cookies(); // async since Next 15
const theme = store.get('theme')?.value ?? 'light'; return (
<html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
<body>{children}</body>
</html>
);
}
Changing the theme happens in a server action, which needs the same import:
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
The action stores the choice for a year, scoped to the whole site:
export async function setTheme(theme: 'light' | 'dark') {
const store = await cookies();
store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
}
The costs are documented by Next.js itself:
cookies()is a request-time API, so calling it in a layout or page switches that route to dynamic rendering and you lose static prerendering.- With Cache Components enabled, calling
cookies()outside a<Suspense>boundary also prevents prerendering. - HTTP does not allow cookies to be set once streaming has begun, so the cookie must be written with
.setin a Server Function or Route Handler, never during render. - Every request carries the cookie, which adds around 15 bytes.
- Operating system settings are invisible to the server, so a
systemchoice still has to be resolved on the client. The practical combination is the cookie for explicit choices andmatchMediafor the system case.
There is a client hint, Sec-CH-Prefers-Color-Scheme, that would expose the OS preference to the server. It is only a draft from the WICG, ships in Chromium alone and is not a standard, so treat it as an optimisation at most, not a general solution.
Preventing the flash of the wrong theme
This is the third problem from the start, and the most common dark-mode defect in shipped sites. The "right way versus lazy way" debate usually does not mention it at all. The timeline shows why a deferred script is too late:
DEFERRED SCRIPT: [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK] <- user sees it
BLOCKING INLINE: [HTML][JS][CSS][PAINT: DARK] <- correct first paint
The theme has to be set on html before the first paint. The fix is a tiny inline script in the head, placed before the stylesheet:
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<script>
// inline. no src, no defer, no async, no type=module.
(function () {
try {
var s = localStorage.getItem('theme'); // 'light'|'dark'|'system'|null
var dark = s === 'dark' ||
((!s || s === 'system') &&
matchMedia('(prefers-color-scheme: dark)').matches);
var el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) { /* storage throws in private mode / sandboxed iframes */ }
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
Every constraint in that snippet matters. It must be inline and synchronous, with no src, defer, async or module type, because any of those would let the browser paint first. It reads a three-valued preference and resolves system through matchMedia. It sets both the attribute and colorScheme, so tokens and browser UI agree. And it wraps storage access in try/catch, because localStorage throws in private browsing modes and sandboxed iframes.
The real price is security policy: an inline script requires 'unsafe-inline' or a nonce in your CSP. If your policy allows neither, use the cookie approach.
In Next.js you also need suppressHydrationWarning on the html element, because the script changes its attributes before React hydrates and they no longer match the server markup. As the next-themes documentation notes, the flag only applies one level deep, so it does not hide hydration warnings anywhere else.
Reading the next-themes source shows how little is behind its flash prevention. It renders a <script dangerouslySetInnerHTML> whose content is its own script() function, converted to a string with script.toString() and called immediately with JSON-serialised arguments. Its useTheme() hook returns theme, setTheme, resolvedTheme, systemTheme and themes, and theme is undefined during server rendering. Render your toggle from resolvedTheme behind a mounted check, or the toggle button itself will cause a hydration error.
color-scheme: the property most sites never set
Your stylesheet paints what you wrote, but the browser itself draws scrollbars, native form controls and the canvas behind the page. The CSS Color Adjustment specification requires the user agent to match all of these to the element's colour scheme:
- the default colours of scrollbars and interactive UI
- the default look of form controls
- extra browser UI, for instance spell-check underlines
- system colours like
Canvas,CanvasText,ButtonFace,FieldandAccentColor - the result of
light-dark()
On the root element, the scheme additionally controls the canvas surface colour and the viewport scrollbars. Declare it in three places. First, a meta tag that the HTML parser sees before any CSS arrives:
<!-- parsed at HTML-parse time, BEFORE any CSS loads -->
<meta name="color-scheme" content="light dark">
Second, CSS rules that keep the property in sync with your theme attribute:
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"]{ color-scheme: light; }
Third, where needed, a lock that keeps a specific widget light regardless of the theme:
/* force a widget to stay light regardless */
.brand-widget { color-scheme: only light; }
The meta tag is not redundant. The HTML Standard's section on the color-scheme meta exists precisely so the browser can paint the page background in the right scheme immediately, without waiting for stylesheets. The CSS property is only known after the stylesheet has been downloaded and parsed; that gap is a white canvas flash. The standard also allows at most one such meta element per document.
Two further pitfalls:
- The property and the media query are unrelated. Declaring
color-scheme: darknever causesprefers-color-scheme: darkto match, so code that reads one to infer the other will misbehave. - The
onlykeyword tells the browser it may not override the element's scheme. In practice it is how a page keeps Chrome on Android from applying its automatic dark theme. Its compat history is odd: added in Chrome 81, removed in 85 and restored in 98.
Three states instead of a boolean
As soon as the toggle is a boolean, "follow my OS" disappears and the user cannot get it back. The preference needs three values: light, dark and system. Start with a storage key and a media query list:
const STORAGE_KEY = 'theme';
const mq = matchMedia('(prefers-color-scheme: dark)');
The rest of the logic applies a preference, saves it, reads it back with system as the default, and keeps listening to OS changes only while system is selected:
function apply(pref) { // 'light' | 'dark' | 'system'
const dark = pref === 'dark' || (pref === 'system' && mq.matches);
const el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
}function setPreference(pref) {
try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
apply(pref);
}function getPreference() {
try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
catch (e) { return 'system'; }
}// keep following the OS, but ONLY while 'system' is the chosen preference
mq.addEventListener('change', () => {
if (getPreference() === 'system') apply('system');
});apply(getPreference());
Use addEventListener on the MediaQueryList, not addListener. MediaQueryList now inherits from EventTarget, and addListener and removeListener are deprecated, even though many snippets online still use them. next-themes keeps the deprecated methods on purpose, with a source comment saying so, to support older Safari versions.
The control should be a group of three radio buttons, not a checkbox, because three states need three inputs.
Stopping the colour smear during a switch
If elements on the page have colour transitions, switching themes animates hundreds of properties at once and the page visibly smears. The solution used by next-themes in its disableAnimation option injects a temporary style that turns every transition off:
function disableTransitionsTemporarily(nonce) {
const css = document.createElement('style');
if (nonce) css.setAttribute('nonce', nonce);
css.appendChild(document.createTextNode(
`*,*::before,*::after{ transition: none !important }`
));
document.head.appendChild(css);
It returns a function that restores transitions, and a swapTheme helper wraps the theme change between the two:
return () => {
// Deliberate forced synchronous reflow: commit the new colours
// WHILE transitions are still off.
(() => window.getComputedStyle(document.body))();
// Remove on a later task, after the flush has committed.
setTimeout(() => { document.head.removeChild(css); }, 1);
};
}function swapTheme(next) {
const enable = disableTransitionsTemporarily();
apply(next);
enable();
}
The getComputedStyle call looks like dead code and is often deleted. It is a deliberate forced synchronous style flush that commits the new colours while transitions are still disabled. The removal is then pushed to a later task with setTimeout, after the flush has taken effect. Without both the forced restyle and the delayed removal, you still see a partial fade.
If you would rather make the switch a visible effect, the View Transitions API supports the familiar circular reveal. It is Baseline newly available since 2025-10-14, with Chrome 111, Safari 18 and Firefox 144. One requirement is easy to miss: disable the default animations on the root snapshots and set the blend mode on the old one:
::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
::view-transition-new(root) { animation: none; }
Also respect users who have asked for less motion:
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
Do not drop mix-blend-mode: normal from the old snapshot. With the default plus-lighter blending, the screen briefly turns milky white partway through a dark-to-light switch, which looks like, and gets reported as, a flash bug.
What still breaks after the tokens flip
Images and SVG
A <picture> element with media="(prefers-color-scheme: dark)" follows the OS setting only. It cannot see your theme attribute or your color-scheme, so a manual toggle leaves images out of sync with the rest of the interface, a common shipped bug. Background images declared inside your dark token block do follow the toggle. For SVG, currentColor works in inline <svg> and in <svg><use href="…">, but not for SVGs loaded through <img src> or CSS url(), because those are separate documents that never inherit your color. Either put prefers-color-scheme queries inside the SVG file itself, or use mask-image together with background-color: currentColor.
Iframes
The colour adjustment spec says that when an iframe's colour scheme differs from that of the embedded document's root, the browser must draw an opaque canvas in the embedded document's Canvas colour instead of a transparent one. In practice this punches a white rectangle into a dark page. Giving the <iframe> element a color-scheme corrects that first canvas, yet a cross-origin document's own CSS stays out of reach. YouTube, Stripe Elements, Disqus and Turnstile embeds each expose a separate theme option; no CSS-level fix exists.
theme-color
Support for the theme-color meta is much weaker than people expect. Firefox has not supported it on any platform; desktop Chrome, from version 73, applies it only to installed PWAs; Safari adopted it in version 15 but, starting with Safari 26, honours it only in installed web apps. MDN lists it as having limited availability, not Baseline status. The spec also lets browsers adjust the colour as they see fit, for example darkening it to keep contrast adequate, so do not rely on exact rendering.
Forced colours and contrast preferences
In Windows High Contrast mode (forced-colors: active), the browser takes control of properties including background-color, color, border-color, outline-color, text-decoration-color and the SVG fill and stroke. Both box-shadow and text-shadow are reset to none, and color-scheme is fixed at light dark. Any elevation that relies on shadows disappears, so replace it with borders in system colours:
@media (forced-colors: active) {
/* your shadow-based elevation is gone — replace it */
.card { border: 1px solid CanvasText; box-shadow: none; }
.btn { border: 1px solid ButtonText; }
}
Which system colour an element gets depends on its native HTML semantics, not its ARIA role, so a <div role="button"> is not given ButtonText. MDN is firm that you should not build a separate design for forced colours, only make small legibility adjustments.
A further axis is often forgotten altogether: prefers-contrast, with the values no-preference, more, less and custom, Baseline widely available since 2022-05-31 and independent of the colour scheme. Dark themes that never check for more are a frequent accessibility gap.
Designing the dark palette
Use dark grey, not black
Material's guidance chooses dark grey rather than black for dark backgrounds and surfaces, because grey keeps shadows visible and reduces eye strain with light text. Google's dark-theme codelab adds a point about foreground colour: pure #FFFFFF text on a dark background can appear to bleed or blur and seems to vibrate, which hurts legibility.
State this carefully. The frequently repeated idea that pure black causes "halation" does not appear to be backed by any controlled study. What is supported is the bleeding and vibration effect of pure-white text, and the choice of grey over black justified by shadow visibility and eye strain.
Express elevation with lightness
Shadows work poorly on dark themes, so Material compensates by making surfaces lighter, and slightly more colourful, the higher they sit. color-mix() makes this easy to derive from a single base:
[data-theme="dark"] {
--surface-1: #12141a;
--surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
--surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
--surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
}
color-mix() has been Baseline widely available since 2025-11-09. Be aware that the Material Design 2 elevation-overlay system is obsolete: Google's documentation says the overlays were replaced by the tonal surface colour system and are no longer maintained. Material 3 uses roles from surfaceContainerLowest to surfaceContainerHighest, plus surfaceDim and surfaceBright. A reference that hands you the old table of 5 percent at 1dp up to 16 percent at 24dp is quoting a deprecated system.
Desaturate accents
Saturated mid-tone accents vibrate against dark surfaces. OKLCH makes the adjustment systematic: its lightness channel is perceptually uniform, so steps that are numerically even also look even, which is exactly where HSL fails and why HSL dark ramps turn muddy in the middle. A lighter, less chromatic accent for the dark theme looks like this:
:root { --accent: oklch(0.55 0.18 255); } /* darker tone on light bg */
[data-theme="dark"] { --accent: oklch(0.72 0.14 255); } /* lighter, less chroma */
Check contrast again for the dark theme
A palette that passes contrast in light mode tells you nothing about dark mode. The WCAG 2.x relative-luminance formula is short enough to keep in a script:
const lum = ([r, g, b]) => {
const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
The contrast ratio then divides the lighter luminance by the darker, each offset by 0.05:
const contrast = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
Running this over every colour used for reading text reliably catches problems eyes miss. Consider a warm paper-coloured theme where everything looked fine on screen: an amber measured 2.06:1, a keyword red used in syntax highlighting 2.80:1, and a gold used for numbers 2.50:1. All three had been approved by eye and all three fail AA.
The WCAG 2.x formula has a known blind spot: it treats light-on-dark and dark-on-light identically, so a dark palette can score well yet still look glaring. That weakness is why APCA was developed, but APCA is not part of WCAG 2.2 and is not normative anywhere yet.
What research says about dark mode and legibility
This point is often stated backwards. The research summary from Nielsen Norman Group reports:
- For people with normal vision, light mode performed better on every measure in studies by Piepenbrock et al. (2013, Ergonomics) and Dobres et al. (2017, Applied Ergonomics). The advantage grows as font size shrinks. The explanation is optical: dark text on a light background produces more light, the pupil contracts, and a smaller pupil means fewer spherical aberrations and greater depth of field.
- For people with low vision, Legge et al. (1985, Vision Research) found that all seven participants with cloudy ocular media, notably cataracts, read faster in dark mode.
- Over the long term, Aleman et al. (2018, Scientific Reports) suggest that sustained exposure to light mode may be associated with myopia through thinning of the choroid.
- The practical recommendation is to let users switch to dark mode if they want to.
The honest reading is that dark mode serves personal preference and certain accessibility needs; it is not a demonstrated readability improvement for the general population. That is the strongest argument for a three-state control rather than shipping dark as the default.
Key takeaways
- Treat dark mode as three problems: choosing the theme, switching colours and applying the choice before first paint.
- Name tokens after roles, flip them in one
data-themeblock, and keepcolor-schemeon the root in sync with it, backed by the meta tag. - Decide the theme before first paint, either with a server-read cookie or with a blocking inline script, and accept the CSP trade-off that the script brings.
- Offer light, dark and system as three states, and follow OS changes only while system is selected.
- If nobody needs a toggle,
prefers-color-schemeover a token layer is the fastest and simplest solution. - Never use
filter: invert()on a page you control: it turns the root into the containing block for every fixed element. - Recheck contrast, images, iframes and forced-colours behaviour separately for the dark theme; flipping tokens does not fix them automatically.