TL;DR
A reliable dark mode toggle UI stores the preference (light, dark, or system), not just the color on screen. Resolve that preference before the main UI paints, let System follow device changes, label each option, and test the app in both themes. Use a two-state switch only when following the system is not a user-facing option.
A dashboard theme switch is easy to draw and surprisingly easy to get wrong. A moon icon can appear to work while a reload forgets the choice, a system change overrides it, or the control announces nothing useful to a screen reader. We will build the preference lifecycle first, then make the interface reflect it. This guide covers app-level decisions, not a component’s complete prop reference.
Table of contents
- Before you start
- 1. Choose a two-state switch or a three-mode selector
- 2. Give saved choices precedence over the device
- 3. Resolve the theme before the main UI paints
- 4. Render a labelled, keyboard-friendly selector
- 5. Audit both themes as real application UI
- 6. Test the preference lifecycle, not just the click
- Frequently asked questions
- Your next step
- Sources
- Recommended Reads
Before you start
You need permission to edit the page’s <head>, a global stylesheet, and the settings or app-header UI. The example uses ordinary HTML, CSS, and browser JavaScript, so you can adapt it to a Vue app without requiring a particular framework package. Serve it from a normal web origin when checking persistence; browser storage behavior for file: URLs is not dependable. MDN documents the storage constraints.
Have both light and dark colors for page canvas, fields, overlays, and focus states. Our DOM Studio theming reference shows paired semantic colors for canvas, controls, borders, and focus. Choose one application-owned storage key. If your app already has a theme store, that store and this example must not compete to initialize the document. In an authenticated product, decide separately whether the choice should sync across devices.
1. Choose a two-state switch or a three-mode selector
Action: State the choice your control promises. A light/dark switch answers a binary question: which theme should I show now? A light/dark/system selector answers another question: should this app follow the device, or use an explicit override? If following the device matters, show Light, Dark, and System as distinct choices. Do not bury System behind an unlabeled third click of a sun/moon button.
For a settings screen, we recommend a labelled three-option group. In a tight dashboard header, use a compact Appearance trigger that opens those choices, or show the current mode in a concise menu. Avoid calling the setting “Dark mode: off” when the app is light because the device is currently light; that wording obscures the difference between System and an explicit Light selection. The W3C radio-group pattern describes the selection and keyboard expectations for mutually exclusive choices.
Expected result: a teammate can tell you what happens when the device changes appearance while each option is selected. Troubleshooting: if you cannot explain whether System is available, settle the preference contract before choosing icons or components.
2. Give saved choices precedence over the device
Action: Use one rule: a valid saved explicit preference wins; system follows the device; if nothing was saved, start with System. Resolve the effective theme separately: Light stays light, Dark stays dark, and System resolves through prefers-color-scheme. A user can choose Light on a dark device without changing their device settings. The browser media query reports the device or browser preference, not an app-specific override. MDN explains the media feature.
| Stored preference | Device preference | Rendered theme | On device change |
|---|---|---|---|
| Light | Either | Light | Stay light |
| Dark | Either | Dark | Stay dark |
| System or none | Light | Light | Follow device |
| System or none | Dark | Dark | Follow device |
Expected result: a system appearance change affects only System. Troubleshooting: if it overrides a selected Light or Dark theme, the code is treating the device’s current color as the user’s choice instead of preserving the selected mode.

For a browser-only preference, localStorage persists on the same origin across browser sessions, but storage can be unavailable under browser or user policy. If reading or writing fails, use in-memory state rather than breaking the page. It does not synchronize choices across a user’s devices. If account settings are the product’s authority, specify how anonymous choices merge on sign-in and how account-specific state clears on sign-out. MDN documents the scope and possible exceptions of localStorage.
3. Resolve the theme before the main UI paints
Action: Put this small, synchronous script early in HTML <head>, before the main stylesheet and any deferred application bundle. It reads a validated mode, immediately sets the resolved data-theme on <html>, and tells the browser the color-scheme for native UI. Early placement reduces a wrong-theme flash when a saved choice disagrees with the device. Google’s theme-switch component walkthrough explains the pre-paint concern; MDN describes color-scheme for browser-rendered controls.
<!-- In <head>, before the main stylesheet; use your application's own key. -->
<script>
(() => {
const key = 'app-theme-mode';
const valid = new Set(['light', 'dark', 'system']);
const device = window.matchMedia('(prefers-color-scheme: dark)');
let mode = 'system';
try {
const saved = window.localStorage.getItem(key);
if (valid.has(saved)) mode = saved;
} catch {
// Storage may be blocked; use the in-memory preference.
}
function apply() {
const theme = mode === 'system'
? (device.matches ? 'dark' : 'light')
: mode;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
window.dispatchEvent(new Event('app-theme-change'));
}
function choose(next) {
if (!valid.has(next)) return;
mode = next;
apply();
try {
window.localStorage.setItem(key, next);
} catch {
// This page can still update if persistence fails.
}
}
device.addEventListener('change', () => {
if (mode === 'system') apply();
});
window.addEventListener('storage', (event) => {
if (event.key !== key) return;
mode = valid.has(event.newValue) ? event.newValue : 'system';
apply();
});
window.appTheme = { get mode() { return mode; }, choose };
apply();
})();
</script>
matchMedia() supplies the current match and a change event when the query changes, as MDN documents. The storage listener reflects a choice changed in another same-origin tab; it does not make blocked storage reliable or sync settings across devices. Selecting System saves that mode too, so later device changes still take effect.
Give the stylesheet both variants. This minimal CSS shows the wiring, not a fully audited palette; extend it to every real surface and measure the final contrast.
:root {
--surface: #ffffff;
--text: #171717;
background: var(--surface);
color: var(--text);
}
:root[data-theme='dark'] {
--surface: #171717;
--text: #f5f5f5;
}
body { margin: 0; background: var(--surface); color: var(--text); }
Expected result: reload with a saved Dark choice while the device is light; the app should start dark. Troubleshooting: if the shell briefly appears light, check script placement, styles loaded before it, server-rendered styles, and other code that rewrites <html>. An authenticated server-rendered app needs its authoritative account preference available before the initial response paints; a later account fetch cannot guarantee a matching first paint. This browser-only example is not account sync.
4. Render a labelled, keyboard-friendly selector
Action: Put this group in Appearance settings, with the second script after its markup. Native radio inputs provide programmatic selection state. A <fieldset> and <legend> name the group; visible labels make the choices intelligible without sun, moon, or monitor icons. MDN’s fieldset reference and the W3C radio-group guidance explain this grouping and interaction model.
<fieldset id="theme-choice">
<legend>Appearance</legend>
<label><input type="radio" name="theme" value="light"> Light</label>
<label><input type="radio" name="theme" value="dark"> Dark</label>
<label><input type="radio" name="theme" value="system" checked> System</label>
</fieldset>
<p id="theme-status"></p>
<!-- Place this after the fieldset, near the end of <body>. -->
<script>
const choices = document.querySelectorAll('input[name="theme"]');
const status = document.getElementById('theme-status');
function updateThemeUI() {
for (const choice of choices) {
choice.checked = choice.value === window.appTheme.mode;
}
const mode = window.appTheme.mode;
status.textContent = mode === 'system'
? `Following system (currently ${document.documentElement.dataset.theme})`
: `${mode === 'dark' ? 'Dark' : 'Light'} selected`;
}
updateThemeUI();
document.getElementById('theme-choice').addEventListener('change', (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === 'theme') {
window.appTheme.choose(event.target.value);
}
});
window.addEventListener('app-theme-change', updateThemeUI);
</script>
The checked System input is the starting state in bare markup, not the final source of truth when JavaScript runs: the body script synchronizes it to the saved mode. Put the group in settings rather than inside a keyboard toolbar. Native radio keyboard behavior can vary slightly by browser; check Tab, Shift+Tab, arrow keys, and Space in your supported browsers instead of inventing custom navigation. W3C notes the native variation.
Expected result: selecting Dark checks Dark, updates the page, and survives a reload where storage is available. Troubleshooting: if the correct theme paints but the wrong option is selected, check that the body script runs after the inputs exist. If a component renders after hydration, synchronize its selection from the same preference owner when it mounts.

For a compact app header, reuse that preference owner and offer the modes in a labelled menu or appearance panel. A simple on/off switch is appropriate only for a genuinely binary Light/Dark choice; do not make three choices pretend to be a binary aria-checked control. In DOM Studio, the Theme Switcher component reference shows Light, Dark, and System presentations, including an appearance-settings variant and compact toolbar example. Its modelValue, label, showLabel, size, and variant options describe the component’s interface, not your account preference policy.
Watch a theme switch build
This walkthrough by Adam Argyle complements the preference and control decisions above. Inspect its interaction and presentation choices, then compare its implementation with your app’s persistence contract.
5. Audit both themes as real application UI
Action: Walk through a populated dashboard and a real form, not only an empty settings card. Inspect page canvas, table rows, chart labels, inputs, placeholders, dropdowns, dialogs, validation messages, selected filters, and disabled controls. Use semantic token pairs so a component does not inherit a light-only foreground. Our theming reference illustrates canvas/foreground pairs and separate border, input, and focus tokens.
Measure text against its actual background in each mode. WCAG 2.2 AA specifies at least 4.5:1 for ordinary text and 3:1 for large text, subject to stated exceptions. Visual information needed to identify a control or its state generally needs 3:1 against adjacent colors. Give a selected option a non-color cue, such as a radio’s checked mark or a readable selected label. See W3C’s text-contrast guidance and non-text contrast guidance.
Keep keyboard focus visible in both themes. Do not express it only as a faint shadow: forced-colors modes can remove shadows and override author colors. Check outlines and text labels there, and keep theme transitions off by default or respect reduced-motion preferences. MDN explains forced colors; Google’s component walkthrough demonstrates motion-aware transitions.
Expected result: switching themes never makes a modal, field error, focus ring, or chart legend unreadable. Troubleshooting: if one region stays bright or loses text, search for hard-coded background and foreground colors there before changing the whole palette.
6. Test the preference lifecycle, not just the click
Action: Run this acceptance sequence in your supported browsers and layouts:
- Clear the saved key. Set the device to Dark, reload, and confirm System is selected and the page is dark. Change the device to Light while the page is open; the page should follow.
- Select Dark while the device is Light. Reload, navigate between dashboard and settings routes, and confirm Dark remains selected and rendered. Change the device appearance; the app should stay dark.
- Select Light, open a second tab, change the choice there, and confirm the first tab updates when storage is available. With storage blocked, the current page should still change, but reload and cross-tab persistence are not promised.
- Use only a keyboard to reach the control and change options. Inspect visible focus at a narrow width and at zoom where the settings row wraps.
- Repeat with a screen reader, forced colors, and reduced motion. Inspect form errors, menus, chart status cues, and overlays in both themes.
Expected result: selected mode, effective palette, visible status, and persisted value agree under each test. Troubleshooting: if the page updates while its checked state stays stale, the component may be maintaining a separate selection state. Bind it back to the preference owner. Include sign-in and sign-out in your test matrix if you added account settings.
Frequently asked questions
Should System be the default?
Yes, if both themes are supported and no saved or account-level override exists. The system preference is a useful starting point, not permission to replace a deliberate Light or Dark selection. prefers-color-scheme exposes the device or browser preference. MDN explains the media feature.
Can one switch offer System too?
Not clearly. A binary switch communicates two states; System adds a distinct preference even when it currently renders the same colors as Light or Dark. Prefer three labelled choices or a menu that exposes the current mode. Use a binary switch only when its behavior truly has two states.
Where should a signed-in preference live?
If users expect the same choice on multiple devices, store it with the account and decide which source wins when a local choice exists. The example deliberately covers one browser origin; localStorage is not an account settings service. For the first paint of an authenticated, server-rendered page, make the account’s authoritative choice available to the initial response rather than waiting for a later API call. MDN documents storage scope; Google’s walkthrough describes the first-paint issue.
Your next step
You now have a defined Light/Dark/System preference, early theme resolution, a labelled selector, and an acceptance sequence that exercises more than the click. Wire that preference owner into the app shell, then verify a real form and dashboard in both themes. If you build with DOM Studio, inspect the Theme Switcher examples alongside the semantic theming tokens before adapting the control to your product.
Sources
- MDN: prefers-color-scheme
- MDN: Window.matchMedia()
- MDN: Window.localStorage
- MDN: color-scheme
- MDN: fieldset
- MDN: forced-colors
- W3C WAI: Radio Group Pattern
- W3C WAI: Understanding Contrast (Minimum)
- W3C WAI: Understanding Non-text Contrast
- web.dev: Building a theme switch component
- DOM Studio: Theme Switcher
- DOM Studio: Theming
Recommended Reads
- How to Build a Vue Design System That Scales, for consistent palettes across shared components.
- Vue App Layout: Build a Responsive Dashboard Shell in Vue 3, for putting the selector in a stable workspace header.
- Vue Mobile Navigation: Build an Accessible Responsive Menu, for testing compact controls beside responsive navigation.
