Theme Switcher UI That Doesn’t Betray You: Button, Dropdown, and Remembered Preference

Was this helpful?
UI Reliability Notes
Theme switching without drama (or outages)


Theme Switcher UI That Doesn’t Betray You: Button, Dropdown, and Remembered Preference

Somebody will complain about your theme switcher. If you ship only light mode, they’ll complain at 2 a.m. from a phone in a dark room. If you ship dark mode with a one-frame flash of white, they’ll complain from a migraine. If you ship a preference that doesn’t persist, they’ll complain every single page load, which is the special kind of complaint that makes engineers question their career choices.

This is a production-minded guide to building a theme switcher UI using plain HTML/CSS and a tiny bit of JavaScript: a button toggle, a dropdown with a “System” option, and remembered preference. The goal isn’t “works on my machine.” The goal is “works in the messy world where browsers block storage, pages are cached, and someone measures your CLS.”

Requirements that matter in production

Theme switching looks like UI polish until you ship it at scale. Then it becomes a reliability feature: it touches performance, accessibility, caching, and occasionally security reviews (anything that writes to storage gets side-eye). Define requirements up front, because “dark mode” is not a requirement; it’s a pile of edge cases wearing a trench coat.

Baseline requirements (non-negotiable)

  • Three-state logic: light, dark, and system. Users expect “follow system.” Enterprises expect it too, because IT sets system policies.
  • Persistence: remember a user’s explicit choice across reloads and sessions. If storage is blocked, degrade gracefully.
  • No flash of wrong theme: avoid the “white flash” on first paint when user prefers dark. This is not cosmetic; it’s UX damage.
  • Accessible controls: a toggle button with correct labeling, and a dropdown that’s keyboard-friendly. If it’s not accessible, it’s not done.
  • Low complexity: no frameworks required. Small JS. Simple CSS. Less surface area means fewer bugs.
  • Safe defaults: if anything fails (storage exceptions, JS disabled), the page remains readable.

Nice-to-haves that pay for themselves

  • Multiple themes: even if you only ship light/dark today, structure CSS so adding “sepia” or “high contrast” doesn’t trigger a rewrite.
  • Telemetry hooks: you don’t need creepy analytics, but you do need a way to know if theme persistence is failing for a large cohort.
  • Component compatibility: if your app embeds third-party widgets, you need to decide whether they inherit theme or stay fixed.

One quote worth keeping taped to your monitor: “Hope is not a strategy.” (paraphrased idea, attributed to Gen. Gordon R. Sullivan) Theme switching needs the same attitude: define the failure modes, then engineer around them.

Joke #1: If your theme switcher causes a flashbang effect at midnight, congratulations—you’ve invented a new kind of on-call alert.

Design the contract: themes, sources, and precedence

Most theme switchers break because nobody wrote down the contract. You want a simple, explicit decision tree that you can implement once and then stop thinking about. The UI should be a view on that contract, not the contract itself.

The theme model

Define two separate concepts:

  • Preference: what the user chose: system, light, dark, sepia
  • Effective theme: what you apply right now: usually light or dark or sepia.

“System” is a preference, not a theme. When preference is system, you compute effective theme from prefers-color-scheme. If you blur those concepts, your JS ends up rewriting user preference when the OS changes—and users get mad because they didn’t pick anything.

Precedence order (do not improvise)

  1. Explicit user preference stored client-side (localStorage, cookie, server profile) wins.
  2. System preference via prefers-color-scheme if user preference is system or unset.
  3. Default (usually light) if detection fails or JS is off.

State that’s useful in debugging

I like storing two dataset attributes on <html>:

  • data-theme = light/dark/sepia (effective)
  • data-theme-source = explicit/system/fallback

This seems trivial until you’re debugging a “theme randomly resets” complaint from a privacy-hardened browser. The source tells you whether storage worked.

HTML UI: button + dropdown without accessibility debt

The UI has two jobs: (1) let users switch, (2) communicate the current state. A button alone is fine for two themes, but breaks down when you add “system” or “sepia.” A dropdown is discoverable and scales. Having both sounds redundant—until you’re trying to ship something that works for keyboard users, power users, and people who just want the page to stop blinding them.

What we’re shipping

  • A Toggle button that flips between light and dark. It does not force “system.” It’s a quick action.
  • A Theme dropdown that offers system, light, dark, and sepia.

Accessibility choices

  • Use a real <button> and <select>. Native controls buy you a lot of accessibility for free.
  • Label controls with aria-label or visible labels. Avoid unlabeled icon-only buttons unless you enjoy angry audit reports.
  • Don’t over-engineer with ARIA roles for simple widgets. ARIA is not a DIY kit; it’s a sharp tool.

The HTML in this page header is the reference implementation. Copy it. Change the IDs if you must. Keep the semantics.

CSS strategy: variables, color-scheme, and sane defaults

The easiest way to make theming survivable is to use CSS variables for all color tokens and set them on :root and [data-theme="…"]. If your CSS is full of hard-coded hex values scattered across components, you don’t have themes. You have a future incident.

Use tokens, not vibes

Start with a small set of tokens:

  • --bg, --fg
  • --muted for secondary text
  • --card for surfaces
  • --border
  • --link
  • --focus for focus rings

This is the minimum viable token set that keeps you from re-theming every component manually.

color-scheme is not optional

Modern browsers use color-scheme to decide how to paint built-in UI (scrollbars, form controls in some contexts) and to optimize rendering. If you set dark colors but forget color-scheme: dark;, you’ll get “dark page, bright inputs” or other uncanny UI.

In the CSS above, each theme sets color-scheme. That’s intentional.

Prefer attribute selectors on <html>

Put data-theme on <html> (documentElement). It scopes the theme to the entire document and plays nicely with embedded content. Avoid setting theme classes on <body> if you have scripts that replace body content or if you do server-side rendering with partials; you’ll create weird transitions during hydration.

Transitions: use them carefully

People like smooth fades. SREs like predictable behavior. If you add global transitions like transition: background-color 250ms; on *, you will eventually break something (like skeleton loaders or charts). If you add transitions at all, restrict them to a few elements and respect reduced motion:

  • Use @media (prefers-reduced-motion: reduce) to disable transitions.
  • Don’t transition color-scheme. It’s not that kind of party.

Tiny JS that’s actually robust (and avoids the flash)

There are two JavaScript moments that matter:

  1. Early boot: pick the effective theme before first paint to avoid the flash.
  2. Interaction: update theme on user input and persist it.

Early boot script placement

Put a tiny script in <head> that runs before CSS is applied. It should:

  • Try to read saved preference from localStorage.
  • If saved preference is system or missing, resolve from prefers-color-scheme.
  • Set document.documentElement.dataset.theme immediately.
  • Catch exceptions from storage (yes, it happens) and fall back safely.

The head script in this document does exactly that. It uses try/catch because some browsers throw on storage access in certain privacy modes, and because you might embed this page in a context that denies storage.

Interaction script (the part most people forget to harden)

Below is the rest of the JS. It syncs the dropdown, makes the button toggle work, listens for system theme changes when preference is system, and persists preference.

cr0x@server:~$ cat theme-switcher.js
(function(){
  var storageKey = "theme.preference";
  var root = document.documentElement;
  var btn = document.getElementById("theme-toggle");
  var sel = document.getElementById("theme-select");
  if (!btn || !sel) return;

  function getSystemTheme() {
    var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
    return (mql && mql.matches) ? "dark" : "light";
  }

  function getSavedPreference() {
    try { return localStorage.getItem(storageKey); }
    catch (e) { return null; }
  }

  function savePreference(pref) {
    try { localStorage.setItem(storageKey, pref); }
    catch (e) { /* ignore */ }
  }

  function applyTheme(pref) {
    var effective = pref;
    if (!effective || effective === "system") {
      effective = getSystemTheme();
      root.dataset.themeSource = "system";
    } else {
      root.dataset.themeSource = "explicit";
    }
    root.dataset.theme = effective;
    sel.value = pref || "system";
    btn.setAttribute("aria-label", "Toggle theme (currently " + effective + ")");
  }

  function toggleLightDark() {
    var current = root.dataset.theme || "light";
    var next = (current === "dark") ? "light" : "dark";
    savePreference(next);
    applyTheme(next);
  }

  // Initialize UI from saved preference (or system).
  var pref = getSavedPreference() || "system";
  applyTheme(pref);

  btn.addEventListener("click", function(){
    toggleLightDark();
  });

  sel.addEventListener("change", function(){
    var pref = sel.value;
    savePreference(pref);
    applyTheme(pref);
  });

  // If user follows system, respond to system changes.
  var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
  if (mql && mql.addEventListener) {
    mql.addEventListener("change", function(){
      var pref = getSavedPreference() || sel.value || "system";
      if (pref === "system") applyTheme("system");
    });
  } else if (mql && mql.addListener) {
    // Older Safari
    mql.addListener(function(){
      var pref = getSavedPreference() || sel.value || "system";
      if (pref === "system") applyTheme("system");
    });
  }
})();

Notes that matter:

  • We store the preference, not the effective theme. That’s how “system” remains meaningful.
  • We update the UI after applying the theme. This avoids a mismatch where the dropdown says “System” but you’re forcing dark.
  • We listen for OS changes only when preference is system. Otherwise you override explicit user choice, which is a fast way to get a bug filed by someone important.

Joke #2: A theme switcher without persistence is like a coffee machine that forgets “strong” every morning—technically functional, emotionally unacceptable.

Facts and historical context (yes, it’s relevant)

Theme switching isn’t new, but the browser platform around it has changed drastically. A few useful facts help you make better decisions and avoid cargo-cult code.

  1. Early “themes” were often image-based. In the 2000s, “skinning” frequently meant swapping background images and sprites. It looked cool, and it loaded like a truck full of bricks.
  2. System-level dark modes made user preference portable. When OSes introduced system-wide appearance settings, users stopped thinking “this app has a dark theme” and started expecting everything to follow their device.
  3. prefers-color-scheme changed the default expectation. Once browsers exposed system preference via media queries, the burden shifted: ignoring it became an accessibility and comfort issue, not a style choice.
  4. “Flash of unthemed content” predates dark mode. FOUC was originally about CSS loading late and showing unstyled HTML. Dark-mode flashes are just the modern variant.
  5. color-scheme is relatively recent and easy to miss. It exists because form controls and scrollbars are not purely CSS-driven everywhere. Without it, you get inconsistent UI affordances.
  6. LocalStorage is synchronous. That’s why it’s convenient for early boot but dangerous if you do heavy writes. Reads are typically fine; large writes in hot paths are not.
  7. Some environments throw on storage access. Private browsing modes, embedded webviews, and strict privacy settings can cause localStorage to throw exceptions instead of returning null.
  8. Enterprise IT can enforce appearance policies. A “system” option aligns your app with managed desktops. Without it, you’re fighting policy with CSS.

Three corporate mini-stories from the theme trenches

Incident: the wrong assumption that “system” is a theme

A mid-sized internal dashboard shipped a theme dropdown with three options: Light, Dark, and System. Under the hood, it stored whatever the dropdown said into localStorage and applied that value as a class on <body>. The CSS had rules for .light and .dark. You can see the plot twist coming.

On day one, it looked fine, because most users picked Light or Dark. But the “System” option was popular among laptop users who roam between office and home setups. When those users selected System, the app stored system and applied class system. CSS didn’t define it, so the page fell back to default styling—mostly light mode, except for components that had been partially refactored to use variables. The result: mixed theme. Text contrast failures. Buttons that looked disabled but weren’t.

Support tickets came in as “random” UI corruption. Engineers initially suspected caching or partial deployments because it didn’t reproduce consistently. It did reproduce consistently; it just required the user to pick “System.” The assumption was the bug: treating “system” as a theme rather than a preference that resolves to an effective theme.

The fix was boring and fast: store preference separately, compute effective theme at runtime, and set a single attribute on <html>. They also added data-theme-source for sanity checks. The next time someone yelled “it’s random,” it wasn’t.

Optimization that backfired: caching the theme too aggressively

An e-commerce team wanted to eliminate the dark-mode flash and shaved it down to near-zero by rendering the theme on the server using a cookie. Good instinct. They added a reverse proxy cache in front of the app and started caching HTML aggressively for logged-out users. Still good, if you’re careful.

They were not careful. They cached HTML responses without varying by the theme cookie. That meant the first request after a cache miss populated the cache with either light or dark HTML, depending on whoever happened to hit it first. Everyone else got that cached variant. Users saw their theme “randomly” change between visits, because the cache rotated content based on expiry, not on preference.

Worse, the CSS file included embedded theme-specific variables generated server-side. So the cache poisoning wasn’t just HTML; it affected the CSS payload too. The team blamed browsers, then blamed CDNs, then blamed a full moon. Meanwhile, users just saw inconsistency and assumed the site was flaky.

The rollback was painful because the caching change touched performance budgets. The eventual fix: vary cache by the cookie when present, but also keep a robust client-side resolver as a backstop. And they stopped generating theme-specific CSS at request time; they shipped static CSS with data attributes. The “optimization” became a stability tax until they changed the architecture.

Boring but correct: a tiny head script that saved the day

A finance team built an internal reporting tool used on big monitors in a bright office and on laptops in dim conference rooms. They were strict about accessibility because auditors were involved. Their UI was server-rendered, with a sprinkling of JS.

When they added dark mode, the first prototype was “fine” but had the usual flash on load. It didn’t show up in dev as much because dev boxes were fast and local. In production, with real latency and an injected analytics script, the flash was very visible.

Instead of rewriting the app or bringing in theming libraries, they did something unsexy: they added a 20-line head script that reads preference and sets data-theme before the CSS loads. They also wrote one integration test that loads the page with a pre-set localStorage value and asserts that the first paint is already themed.

That’s it. No heroics. No platform rewrite. The auditors stopped finding contrast regressions because tokens were centralized. The on-call stopped getting “my eyes” tickets. It’s a good reminder that the “boring” solution is often the reliable one.

Practical tasks: commands, expected output, and decisions

You can build this in a codepen and call it a day. Or you can ship it in a real environment where build steps, caching, headers, and regressions exist. These are operational tasks I’d actually do (or ask someone to do) before calling it production-ready.

Task 1: Verify your HTML sets data-theme before first paint (basic grep)

cr0x@server:~$ grep -n "document.documentElement.dataset.theme" -n index.html
42:      document.documentElement.dataset.theme = theme;
48:      document.documentElement.dataset.theme = "light";

What it means: You have an early setter. If it’s only in a deferred bundle, you’ll still flash.

Decision: If the setter isn’t in head or runs too late, move it into an inline head script.

Task 2: Confirm the head script runs before external CSS (ordering check)

cr0x@server:~$ awk 'NR<=80{print NR ":" $0}' index.html
1:

2:
3:
4:  
5:  
6:  Theme Switcher UI That Doesn’t Betray You: Button, Dropdown, and Remembered Preference
...
23:  
113:  
114:  

What it means: In this example, CSS is inline and the boot script is after it, which is still okay because the script runs immediately during parsing, but you should be deliberate. With external CSS, you want the boot script before CSS loads or at least before render.

Decision: If you use external CSS files, place the boot script above <link rel="stylesheet"> or inline minimal CSS and set theme before the full CSS applies.

Task 3: Check for storage exceptions in a locked-down environment

cr0x@server:~$ node -e 'console.log("Simulate: localStorage may throw in some browsers; ensure try/catch exists")'
Simulate: localStorage may throw in some browsers; ensure try/catch exists

What it means: This “task” is about process: you must code as if storage can fail. You can’t reliably reproduce every browser privacy mode in CI.

Decision: Keep try/catch around storage reads/writes. Treat missing preference as “system.”

Task 4: Validate the JS bundle doesn’t accidentally overwrite the early theme

cr0x@server:~$ grep -R --line-number "dataset.theme =" dist/ | head
dist/app.js:812:root.dataset.theme = effective;

What it means: Your main JS sets theme too, which is fine if it uses the same logic and preference key. It’s not fine if it always defaults to light.

Decision: Ensure both early boot and interaction code share the same preference source and precedence rules.

Task 5: Confirm your CSS has no hard-coded colors that break themes

cr0x@server:~$ grep -R --line-number -E "#[0-9a-fA-F]{3,6}\b|rgb\(|hsl\(" src/styles | head
src/styles/components/buttons.css:12:  border: 1px solid var(--border);
src/styles/components/layout.css:4:  background: var(--bg);

What it means: Ideally the grep finds mostly token usage. If it finds random hex codes, they’ll likely look wrong in one theme.

Decision: Replace component colors with variables. Leave a few “brand accent” constants only if you’ve tested them in both modes.

Task 6: Lint for accidental global transitions

cr0x@server:~$ grep -R --line-number "transition:" src/styles | head
src/styles/base.css:88:  transition: background-color 250ms ease, color 250ms ease;

What it means: Global transitions can introduce jank and unexpected animations in charts or skeletons.

Decision: If transitions apply to large DOM subtrees, scope them to a small set of containers or remove them.

Task 7: Check that the served HTML isn’t cached incorrectly when using cookies

cr0x@server:~$ curl -I -H "Cookie: theme=dark" http://localhost:8080/ | egrep -i "cache-control|vary|set-cookie"
Cache-Control: public, max-age=300
Vary: Accept-Encoding

What it means: If you serve theme via cookie and cache HTML, you likely need Vary: Cookie or to disable caching for personalized HTML. The output above does not vary by cookie, so a cache can mix themes.

Decision: Either avoid server-side theming for cached pages, or vary/segment caches correctly.

Task 8: Confirm your CSP allows the inline head script (or plan a nonce)

cr0x@server:~$ curl -I http://localhost:8080/ | egrep -i "content-security-policy"
Content-Security-Policy: default-src 'self'; script-src 'self'

What it means: script-src 'self' blocks inline scripts. Your early boot inline script won’t run.

Decision: Add a nonce for the inline script, or load a small external boot script with high priority. If you can’t, accept that you may get a flash.

Task 9: Validate prefers-color-scheme behavior in headless smoke tests

cr0x@server:~$ node -e 'console.log("Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.")'
Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.

What it means: Your CI might not emulate dark mode. Tests that assume dark by default will fail unpredictably.

Decision: In E2E tests, set the preference explicitly (storage or emulation) and assert data-theme.

Task 10: Measure whether the theme flash is visible (quick performance sniff)

cr0x@server:~$ google-chrome --headless --disable-gpu --dump-dom http://localhost:8080/ | head
<!doctype html><html lang="en" data-theme="light" data-theme-source="system">...

What it means: Dumped DOM shows theme attribute is set early. This doesn’t fully prove no flash (paint timing is hard headless), but it’s a useful sanity check.

Decision: If data-theme isn’t present in the dumped HTML, your early boot didn’t execute, likely due to CSP or ordering.

Task 11: Confirm the UI controls match stored preference

cr0x@server:~$ node -e 'console.log("Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.")'
Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.

What it means: If the dropdown always resets visually, users will think the setting didn’t save even if it did.

Decision: Ensure you set select.value from the stored preference, not from effective theme.

Task 12: Verify the theme attribute is not being stripped by HTML sanitizers

cr0x@server:~$ curl -s http://localhost:8080/ | head -n 3



What it means: Some templating or sanitization layers strip unknown attributes. If data-theme disappears, CSS won’t apply as expected.

Decision: If attributes are stripped, apply theme via class or configure the sanitizer/templating engine to allow data-*.

Task 13: Check that your third-party widgets don’t hardcode colors

cr0x@server:~$ grep -R --line-number "style=" public/widgets | head
public/widgets/legacy-chat.html:17:<div style="background:#fff;color:#000">Chat</div>

What it means: Inline styles will ignore your token system. In dark mode, you’ll get bright boxes embedded in a dark page.

Decision: Refactor widget styling to use variables, or sandbox it visually (frame it as “always light”) so it looks intentional.

Task 14: Confirm the server doesn’t compress/alter inline scripts in a way that breaks them

cr0x@server:~$ curl -s -D - http://localhost:8080/ -o /dev/null | egrep -i "content-encoding|content-type"
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip

What it means: Compression is fine. What you’re looking for is unexpected rewriting (some “optimizers” rewrite inline scripts).

Decision: If you use HTML rewrite middleware, exclude the head boot script from transformations or move it to a static file.

Fast diagnosis playbook

When theme switching breaks, people describe symptoms like “random,” “flickers,” or “ignores my setting.” Don’t chase vibes. Run a tight triage sequence that gets you to root cause quickly.

First: determine whether the problem is persistence, resolution, or paint timing

  1. Persistence check: After setting Dark, reload. Does it stay dark? If not, storage isn’t being read or written.
  2. Resolution check: If preference is “System,” does OS switching change the theme? If not, media query listening is missing or broken.
  3. Paint timing check: Does the correct theme eventually apply, but you see a flash first? Then the boot script is too late or blocked by CSP.

Second: inspect the actual source of truth in the DOM

  • Look at <html data-theme="..." data-theme-source="...">. If data-theme-source is fallback, storage likely threw.
  • Confirm the dropdown value reflects preference, not effective theme.

Third: verify cache and policy interference

  • If you use cookies for theme on the server, check cache headers and Vary.
  • Check CSP. Inline boot scripts frequently die here.
  • Check if a corporate browser policy disables persistent storage for the site.

The bottleneck question

The usual bottleneck is not CPU. It’s ordering: the browser paints before your theme decision runs. Fix that first. Then worry about elegance.

Common mistakes: symptom → root cause → fix

1) Symptom: “Dark mode flashes white on reload”

Root cause: Theme is applied after first paint (JS bundle deferred, or runs after CSS loads).

Fix: Inline a minimal head boot script to set data-theme before render; ensure CSP allows it or use a nonce/static boot file.

2) Symptom: “I select System and the UI looks half-themed”

Root cause: Treating system as a theme class, instead of resolving it to an effective theme.

Fix: Store preference (system) but apply effective theme (light/dark) to data-theme. Don’t create .system CSS unless you mean it.

3) Symptom: “Setting doesn’t persist, works only until I close the tab”

Root cause: Using sessionStorage unintentionally, or storage writes failing due to exceptions.

Fix: Use localStorage with try/catch, or fall back to a cookie. Keep the app usable if persistence fails.

4) Symptom: “Dropdown says System but page is forced to Dark”

Root cause: UI state is derived from effective theme rather than stored preference.

Fix: Always set dropdown value from preference; compute effective theme separately.

5) Symptom: “Theme changes on its own when OS theme changes, even though I picked Dark”

Root cause: Listening to prefers-color-scheme changes unconditionally and reapplying “system” behavior.

Fix: Only respond to system changes when preference is system.

6) Symptom: “Inputs and scrollbars stay light in dark theme”

Root cause: Missing color-scheme declaration for dark theme.

Fix: Set color-scheme: dark; inside your dark theme selector.

7) Symptom: “Users report random theme, but only in production”

Root cause: Cache mixing content across theme variants (cookie-based theming + shared cache), or CSP blocking the early boot script.

Fix: Fix caching (vary/disable for personalized HTML) and confirm CSP supports the theme bootstrap.

8) Symptom: “Theme toggle works, but the page becomes sluggish”

Root cause: Massive DOM updates due to transitions or recalculations; sometimes due to applying theme to many nodes individually.

Fix: Apply theme at the root (<html>) only. Avoid global transitions. Keep theme tokens small.

Checklists / step-by-step plan

Step-by-step: ship a reliable theme switcher

  1. Define preferences: Decide the allowed preference values (system, light, dark, optional extras).
  2. Define tokens: Pick a small set of CSS variables that all components use.
  3. Implement theme selectors: :root for default, [data-theme="dark"] etc. Keep it at the <html> level.
  4. Add color-scheme: Light theme sets light, dark theme sets dark.
  5. Write the head boot script: Read preference (try/catch), resolve system preference, set data-theme.
  6. Add UI controls: Native button and select, labeled, keyboard-friendly.
  7. Write interaction script: Change preference, persist, apply effective theme, sync the UI.
  8. Handle system changes: Listen to prefers-color-scheme changes only when preference is system.
  9. Test with storage blocked: Confirm the page remains readable and doesn’t throw errors that break other scripts.
  10. Test with CSP: Ensure your inline boot script is allowed (nonce) or move it to a static file.
  11. Test caching behavior: If theming is server-side for any path, confirm cache segmentation by preference.
  12. Ship and monitor: Add lightweight logging for theme persistence failures if your environment allows it (e.g., count exceptions without capturing user data).

Pre-flight checklist (what I’d do before enabling by default)

  • Page loads with correct theme when preference is set (no flash visible in real browser, not just headless).
  • Dropdown and button states always reflect the real preference/effective theme.
  • With JS disabled, page is still readable and controls don’t mislead.
  • With storage blocked, theme selection still works for the session (even if not persisted) and doesn’t crash.
  • Contrast checks pass for body text, muted text, buttons, and focus outlines in all themes.
  • No global transitions causing weird animations.
  • CSP is compatible with the boot approach.

FAQ

1) Should I store the effective theme or the preference?

Store the preference (system/light/dark). Compute the effective theme at runtime. Otherwise “System” becomes meaningless and you’ll override users unexpectedly.

2) Why not just rely on prefers-color-scheme and skip the UI?

Because users want control. Also, enterprise desktops often have system settings that don’t match personal comfort in specific apps. Give them an override.

3) Is localStorage safe for this?

It’s fine for a small string value. The real issue is that it can throw or be unavailable in some privacy modes. Wrap access in try/catch and degrade gracefully.

4) Why put the boot script inline in the head?

To avoid the wrong-theme flash. External scripts load later and can be blocked or delayed. Inline head code runs during parsing and can set data-theme before paint.

5) My CSP blocks inline scripts. What now?

Use a nonce for the inline script, or serve a tiny external “theme boot” script with high priority. If you can’t do either, accept some flash and make it less painful with sensible defaults.

6) Should the toggle button cycle through System → Light → Dark?

No. Keep the button a fast Light/Dark flip. Put System (and any extra themes) in the dropdown. Users understand this split quickly, and it avoids weird state surprises.

7) Do I need to listen for system theme changes?

Only if the user preference is system. Otherwise it’s invasive. Also, don’t forget older Safari uses addListener rather than addEventListener on media queries.

8) Where should I attach the theme attribute, <html> or <body>?

<html>. It’s the root for the whole document, and it reduces weirdness during hydration or body replacement. It also aligns with color-scheme behavior.

9) How do I avoid rewriting tons of CSS?

Use CSS variables as tokens and make components depend on tokens only. Then theme definitions are just token sets. This is the only approach that stays maintainable past the first sprint.

10) Can I add a “high contrast” theme?

Yes, and you should consider it if your app is used for long sessions. Treat it like any other theme: define tokens, add an option, and test contrast and focus indicators carefully.

Conclusion: next steps you can actually do

A theme switcher is a small feature with a surprisingly large blast radius. Done right, it disappears—users get comfort, accessibility improves, and your UI stops fighting the OS. Done wrong, it becomes a low-grade incident generator: flicker tickets, “random behavior” reports, and a slow bleed of trust.

Next steps:

  1. Implement the token-based CSS structure (:root + [data-theme]) and set color-scheme per theme.
  2. Add the head boot script and verify it runs under your CSP and caching setup.
  3. Wire the dropdown to store preference, not effective theme, and keep the toggle as a quick light/dark flip.
  4. Run the fast diagnosis playbook once—on purpose—so you know what the failure modes look like before users find them.


← Previous
ZFS L2ARC: The SSD Cache That Helps… Until It Hurts
Next →
Email “message deferred”: decode why mail is stuck (and fix it)

Leave a comment