Syncing System Preference with a Manual Theme Override
Part of Prefers-Color-Scheme Integration. This page walks through implementing a three-state theme model — system, light, and dark — where prefers-color-scheme drives the default, a manual selection overrides it, and a dedicated “system” option hands control back to the OS, including wiring up live OS-change detection that activates only while the user is in system mode.
prefers-color-scheme and reacts to OS changes; light and dark are manual overrides that silence the OS listener.Prerequisites
Before implementing the three-state model, confirm the following are in place:
- CSS custom properties with theme scoping. Your token sheet must define
[data-theme="light"]and[data-theme="dark"]attribute selectors at the:rootlevel, alongside a@media (prefers-color-scheme: dark)block that only fires when no explicit override is active. The sibling page on implementing prefers-color-scheme without FOUC covers that baseline architecture. - A synchronous inline head script. The three-state resolver must run before the first paint to avoid flash. You need a
<script>tag in<head>— not asrc=external file. localStorageavailable. The implementation stores"light","dark", ornull(system) under a single key. If your environment blocks storage (sandboxed iframes, private-browsing fallback), plan a cookie-based alternative.- A UI control with three options. A segmented button group or
<select>with valuessystem,light,darkis all that is required. Framework choice does not matter — the state lives in the DOM and storage, not in component state. - No framework theme context wiring yet. If you are migrating from a two-state React/Vue toggle, defer that refactor to the migration note at the end of this page.
Step-by-Step Implementation
Step 1 — Establish the CSS token cascade
Define your semantic color tokens with a clear precedence order: media-query default, then manual attribute override.
/* tokens.css — loaded via <link> or @import */
/* Light defaults at :root */
:root {
--color-bg: #ffffff;
--color-surface: #f1f5f9;
--color-text: #0f172a;
--color-text-secondary: #475569;
--color-border: #e2e8f0;
}
/*
OS dark preference fires ONLY when data-theme is absent or "system".
The :not() guard prevents a conflict when the user has locked to light
while their OS is set to dark.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="dark"]) {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
}
/* Explicit overrides — highest specificity, media-query-independent */
[data-theme="light"] {
--color-bg: #ffffff;
--color-surface: #f1f5f9;
--color-text: #0f172a;
--color-text-secondary: #475569;
--color-border: #e2e8f0;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
Why this works. When data-theme is absent, the @media block drives the appearance and the :not() guards have no effect. When data-theme="light" or data-theme="dark" is present, the :not() conditions suppress the media query entirely, so the explicit selector wins on specificity regardless of what the OS reports. Setting data-theme="system" as an attribute value is intentionally avoided — you remove the attribute instead, which returns the cascade to the media-query path cleanly.
Step 2 — Write the inline head resolver
This script runs synchronously before any element is painted. It reads storage, maps the stored value to an attribute (or no attribute for system mode), and exits. No DOM queries, no heavy imports.
<head>
<link rel="stylesheet" href="/tokens.css">
<script>
(function () {
var THEME_KEY = 'ds-theme-pref';
try {
var stored = localStorage.getItem(THEME_KEY);
// stored is "light", "dark", or null (meaning system)
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored);
}
// If null, leave data-theme absent so @media drives the cascade
} catch (e) {
// localStorage blocked — fall through to @media default
}
})();
</script>
</head>
Why this works. Parsing this script blocks layout — intentionally. By the time the browser calculates the first CSS cascade, data-theme is already set (or deliberately absent). There is no frame where the wrong theme colors flash. The try/catch ensures that storage restrictions in sandboxed iframes or strict private-browsing modes do not throw a fatal error.
Step 3 — Define the theme manager module
This module owns all runtime theme logic: reading storage, applying the attribute, and managing the OS-change listener.
// theme-manager.js
const THEME_KEY = 'ds-theme-pref';
const mq = window.matchMedia('(prefers-color-scheme: dark)');
let osChangeHandler = null;
/**
* Apply a theme choice to the document.
* @param {'system'|'light'|'dark'} choice
*/
export function applyTheme(choice) {
const html = document.documentElement;
if (choice === 'system') {
// Remove the attribute so @media takes over
html.removeAttribute('data-theme');
localStorage.removeItem(THEME_KEY);
enableOsListener();
} else {
html.setAttribute('data-theme', choice);
localStorage.setItem(THEME_KEY, choice);
disableOsListener();
}
}
/**
* Return the current stored preference, or 'system' if none.
* @returns {'system'|'light'|'dark'}
*/
export function getStoredTheme() {
try {
return localStorage.getItem(THEME_KEY) || 'system';
} catch (e) {
return 'system';
}
}
function enableOsListener() {
if (osChangeHandler) return; // already attached
osChangeHandler = function (e) {
// Only fires while in system mode (no data-theme attribute)
const html = document.documentElement;
if (!html.hasAttribute('data-theme')) {
// The @media cascade handles the visual change automatically;
// this handler is a hook for analytics or UI indicator updates.
html.dispatchEvent(new CustomEvent('themechange', {
detail: { resolved: e.matches ? 'dark' : 'light', source: 'os' }
}));
}
};
mq.addEventListener('change', osChangeHandler);
}
function disableOsListener() {
if (!osChangeHandler) return;
mq.removeEventListener('change', osChangeHandler);
osChangeHandler = null;
}
// Initialize: attach listener only if in system mode
(function init() {
const stored = getStoredTheme();
if (stored === 'system') {
enableOsListener();
}
})();
Why this works. The matchMedia listener is attached only when the user is in system mode, which eliminates a class of subtle bugs where an OS change overwrites a manual override. Removing the listener on any manual selection (disableOsListener) keeps the event system clean. The CustomEvent dispatch on themechange gives higher-level code — analytics, UI indicators — a hook without coupling them to storage or matchMedia directly. The CSS cascade handles the actual visual update automatically; no imperative color setting is needed inside the listener.
Step 4 — Wire up the UI control
A three-option control is the minimum viable surface. The values system, light, and dark map directly to the module’s API.
<!-- Segmented control — accessible, framework-agnostic -->
<fieldset class="theme-switcher">
<legend class="visually-hidden">Color theme</legend>
<label>
<input type="radio" name="theme" value="system">
System
</label>
<label>
<input type="radio" name="theme" value="light">
Light
</label>
<label>
<input type="radio" name="theme" value="dark">
Dark
</label>
</fieldset>
<script type="module">
import { applyTheme, getStoredTheme } from '/theme-manager.js';
const radios = document.querySelectorAll('[name="theme"]');
// Sync control to current state on page load
const current = getStoredTheme();
radios.forEach(r => { r.checked = r.value === current; });
// Apply choice on user interaction
radios.forEach(r => {
r.addEventListener('change', () => {
if (r.checked) applyTheme(r.value);
});
});
// Keep the control in sync if the OS changes while in system mode
document.documentElement.addEventListener('themechange', (e) => {
// Optionally show which resolved theme is active, without changing the radio
console.info('OS resolved theme:', e.detail.resolved);
});
</script>
Why this works. Setting r.checked on page load synchronizes the visual state of the control to what the head script already applied, so the UI reflects reality without a second storage read. The themechange custom event let you update a visual indicator (e.g., a small sun/moon icon next to “System”) without forcing the control away from “System” — the user chose system, the OS resolved to dark, and both facts are simultaneously true.
Step 5 — Persist across tabs with storage events
When a user changes the theme in one tab, all other open tabs should follow without a reload.
// Add this to theme-manager.js or your main bundle
window.addEventListener('storage', (e) => {
if (e.key !== 'ds-theme-pref') return;
const newValue = e.newValue; // "light", "dark", or null (cleared = system)
const html = document.documentElement;
if (newValue === 'light' || newValue === 'dark') {
html.setAttribute('data-theme', newValue);
disableOsListener();
} else {
// null means the key was removed — return to system mode
html.removeAttribute('data-theme');
enableOsListener();
}
});
Why this works. The storage event fires on every tab except the one that made the write. Mirroring the same attribute-management logic used in applyTheme ensures identical behavior across tabs. Checking e.key before acting prevents unrelated storage changes from triggering unnecessary DOM mutations.
Step 6 — Guard the resolved theme for SSR
On server-rendered pages, the server cannot know the OS preference. Emit a data attribute the client can use to confirm agreement, and suppress React/Vue hydration warnings on this one attribute.
<!-- SSR template: server reads cookie, falls back to no attribute -->
<html
data-theme="{{ cookieTheme }}"
data-ssr-theme="{{ cookieTheme || 'system' }}"
suppressHydrationWarning
>
// In your framework hydration entry point — runs after mount
import { getStoredTheme, applyTheme } from '/theme-manager.js';
const stored = getStoredTheme();
const ssrTheme = document.documentElement.dataset.ssrTheme;
// If SSR guessed wrong, reconcile immediately after mount
if (stored !== ssrTheme) {
applyTheme(stored);
}
Why this works. suppressHydrationWarning (React) or its Vue/Svelte equivalent tells the framework not to throw on data-theme mismatches during hydration, because we reconcile them deliberately in the post-mount step. The data-ssr-theme attribute records what the server believed, allowing the client to detect a divergence. For deeper SSR alignment, see Advanced Theming & Dark Mode Implementation for the full SSR hydration pattern, and the companion page on persisting user theme choice without a flash on reload for cookie-based server reads.
Step 7 — Verify the implementation
Run these checks in sequence to confirm the three-state model is wired correctly.
// Paste in DevTools console on your running page
// 1. Confirm system mode: no data-theme attribute, OS listener active
localStorage.removeItem('ds-theme-pref');
document.documentElement.removeAttribute('data-theme');
location.reload();
// After reload: document.documentElement.getAttribute('data-theme') should be null
// 2. Confirm manual dark: attribute set, persists across reload
localStorage.setItem('ds-theme-pref', 'dark');
location.reload();
// After reload: document.documentElement.getAttribute('data-theme') === 'dark'
// 3. Simulate OS change while in system mode
localStorage.removeItem('ds-theme-pref');
document.documentElement.removeAttribute('data-theme');
// Open DevTools > Rendering > Emulate CSS media feature: prefers-color-scheme: dark
// The page should shift to dark tokens without a JS error
// Switching back to light should shift tokens back
// 4. Confirm OS change is ignored while in manual mode
localStorage.setItem('ds-theme-pref', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
// Change emulated OS preference to light — page should stay dark
In Playwright, add a dedicated suite:
// e2e/three-state-theme.spec.js
import { test, expect } from '@playwright/test';
test('system mode respects OS preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.evaluate(() => localStorage.removeItem('ds-theme-pref'));
await page.reload();
const attr = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(attr).toBeNull(); // OS handled by CSS, no attribute needed
});
test('manual dark persists across reload', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' }); // OS says light
await page.evaluate(() => localStorage.setItem('ds-theme-pref', 'dark'));
await page.reload();
const attr = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(attr).toBe('dark'); // Manual override wins
});
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Manual override not sticking after reload | The head resolver script is external (src=) rather than inline, so it runs after paint. |
Move the resolver into an inline <script> directly in <head>, before any <link> that loads component CSS. |
| OS change ignored while in system mode | enableOsListener() was never called, or the listener was attached before the DOM was ready and a subsequent disableOsListener() cleared it. |
Confirm init() runs at module load time and that getStoredTheme() returns 'system' before listener attachment. Log mq.addEventListener calls with a debug flag. |
| OS change over-applies and resets a manual override | The storage event handler or a stale matchMedia listener is still active after a manual selection. |
Call disableOsListener() inside every branch of applyTheme that is not 'system', and verify the osChangeHandler reference is nulled after removal. |
| Flash of wrong theme on initial load | The CSS @media block fires before the head script sets data-theme, momentarily applying OS dark while the stored preference is light. |
Add the :not([data-theme="light"]):not([data-theme="dark"]) guards to the media query rule as shown in Step 1. |
| Two tabs disagree after switching | The storage event listener is missing or keyed to the wrong localStorage key. |
Confirm e.key === 'ds-theme-pref' check is present and the same key constant is used everywhere. |
Migration Note: From a Two-State Toggle
If you are replacing a boolean isDark toggle (e.g., a React context value or a single localStorage.setItem('dark', '1')), the migration is three phases:
Phase 1 — Parallel storage. Keep the old boolean key alive. In the new applyTheme, read the old key as a migration fallback: if ds-theme-pref is absent but dark === '1' exists, treat it as 'dark'. Write the new key on first use. Remove the migration read after one release cycle.
// One-time migration shim — remove after one deploy cycle
(function migrateOldKey() {
var old = localStorage.getItem('dark');
if (old !== null && !localStorage.getItem('ds-theme-pref')) {
localStorage.setItem('ds-theme-pref', old === '1' ? 'dark' : 'light');
localStorage.removeItem('dark');
}
})();
Phase 2 — Introduce the system option. Update the UI from a toggle to the three-option control. Users who never explicitly chose a theme will land in system mode, which is the correct default.
Phase 3 — Remove legacy CSS. Delete any class-based selectors like .dark-mode body or html.theme-dark. Everything should now be [data-theme="dark"] at the :root level, consistent with Step 1’s token architecture.
The key invariant to maintain throughout: a null value in storage always means “defer to OS,” never “light.” If your old code stored null to mean light, add an explicit 'light' write when the user picks light before removing the old key.
Related
- Prefers-Color-Scheme Integration — parent: CSS cascade architecture,
@mediatoken mapping, and FOUC prevention for the full integration context. - Persisting User Theme Choice Without a Flash on Reload — cookie-based and localStorage persistence patterns that complement this three-state model.
- Advanced Theming & Dark Mode Implementation — the broader scope: runtime switching, SSR hydration alignment, and forced-colors support.