Automatic light and dark theme switching

Intro

Every app I build gets the same switch: System, Light, Dark. System is the default, it follows the operating system while the app is open, and whatever the visitor picks is still there after a reload.

It is not much code. But a few details are easy to get wrong, and one of them - the flash of the wrong theme on load - is the kind of thing people notice immediately. Here is the whole thing, web first.

Put the colours in one place

Every colour on the page comes from a custom property, and light-dark() gives each one a light and a dark value:

CSS
:root {
  color-scheme: light dark;

  --bg:     light-dark(#f7f8f4, #14181a);
  --panel:  light-dark(#ffffff, #1d2225);
  --ink:    light-dark(#151816, #e8ebe6);
  --muted:  light-dark(#6f756f, #9aa3a0);
  --line:   light-dark(#dfe4dd, #2e3539);
  --accent: light-dark(#1f7a67, #4fd1b0);
}

/* The two choices that override the operating system. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

color-scheme: light dark says the page is happy either way, which also gets you themed scrollbars, form controls and the default canvas colour. light-dark() then picks the first value under a light scheme and the second under a dark one.

That makes the whole switch one attribute on <html>: no data-theme means the operating system decides, data-theme="light" or "dark" pins it. Nothing else in the stylesheet knows that themes exist.

If you need to go further back than Chrome 123 / Safari 17.5 / Firefox 120, the same thing with a media query - light is the starting point, dark is applied unless the visitor has explicitly asked for light:

CSS
:root {
  color-scheme: light;
  --bg: #f7f8f4;
  --ink: #151816;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #14181a;
    --ink: #e8ebe6;
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #14181a;
  --ink: #e8ebe6;
}

The :not([data-theme="light"]) is the part people forget. Without it, a visitor on a dark system who asks for light gets a dark page anyway.

No flash of the wrong theme

The saved choice has to be on the element before the first paint. That means a small script, inline, in <head>, above anything that draws:

HTML
<script>
  (function () {
    try {
      var saved = localStorage.getItem('theme');
      if (saved === 'light' || saved === 'dark') {
        document.documentElement.dataset.theme = saved;
      }
    } catch (e) { /* storage can be switched off; the OS choice still works */ }
  })();
</script>

Three rules for that block:

  • Inline. An external file is another request, and the page may well paint before it arrives.
  • No defer, no async, no type="module". All three run after the document is parsed, which is after the browser has already drawn a white page.
  • In a try. localStorage throws, it does not return null, when storage is blocked. An exception here would take the rest of the page with it.

Keep it to that one job. It runs before everything else on every page load, so it has to be a few lines.

The switch itself

Three radio buttons in a fieldset. A radio group gives you arrow-key navigation, "System, radio button, 1 of 3" in a screen reader and a label you can click - none of which you get for free from three buttons with aria-pressed:

HTML
<fieldset class="theme-switch">
  <legend>Theme</legend>
  <input type="radio" name="theme" id="theme-system" value="system" checked />
  <label for="theme-system">System</label>
  <input type="radio" name="theme" id="theme-light" value="light" />
  <label for="theme-light">Light</label>
  <input type="radio" name="theme" id="theme-dark" value="dark" />
  <label for="theme-dark">Dark</label>
</fieldset>

The inputs and the legend are hidden the accessible way - moved out of sight, not display: none, which would take them out of the tab order:

CSS
.theme-switch {
  display: inline-flex;
  padding: 0.25rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
}

.theme-switch legend,
.theme-switch input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.theme-switch label {
  display: block;
  min-height: 40px;             /* a finger, not a mouse pointer */
  padding: 0.45rem 1rem;
  border-radius: 999px;
  color: var(--muted);
  cursor: pointer;
}

.theme-switch input:checked + label { background: var(--accent); color: var(--bg); }
.theme-switch input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }

That last line matters. The thing that has focus is the hidden input, so without passing the focus ring on to the label, keyboard users get no idea where they are.

Applying and remembering the choice

JavaScript
(function () {
  var KEY = 'theme';
  var root = document.documentElement;
  var media = window.matchMedia('(prefers-color-scheme: dark)');

  function read() {
    try {
      var saved = localStorage.getItem(KEY);
      return saved === 'light' || saved === 'dark' ? saved : 'system';
    } catch (e) { return 'system'; }
  }

  function apply(choice) {
    if (choice === 'system') { delete root.dataset.theme; }
    else { root.dataset.theme = choice; }

    try {
      if (choice === 'system') { localStorage.removeItem(KEY); }
      else { localStorage.setItem(KEY, choice); }
    } catch (e) { /* not remembered, but the page still looks right */ }

    paint(choice);
  }

  var current = read();
  var radio = document.getElementById('theme-' + current);
  if (radio) { radio.checked = true; }
  paint(current);

  document.querySelectorAll('input[name="theme"]').forEach(function (input) {
    input.addEventListener('change', function () { apply(input.value); });
  });

  media.addEventListener('change', function () {
    if (read() === 'system') { paint('system'); }
  });
})();

System is stored as nothing at all rather than as the string "system". The absence of a choice is exactly what System means, and it keeps the script in the head down to one comparison.

Note what the page does not have to do when the operating system changes: if every colour comes from CSS, the browser repaints on its own and the change listener is not needed for that at all. It is needed for the handful of things CSS cannot reach - the browser chrome colour, a canvas, a chart library, a widget you hand a theme name to. That is what paint() is for:

JavaScript
function paint(choice) {
  var dark = choice === 'dark' || (choice === 'system' && media.matches);

  var meta = document.querySelector('meta[name="theme-color"]:not([media])');
  if (!meta) {
    meta = document.createElement('meta');
    meta.name = 'theme-color';
    document.head.appendChild(meta);
  }
  // The computed background, not --bg: a custom property hands back the light-dark()
  // call itself, unresolved, and a browser would make nothing of that.
  meta.content = getComputedStyle(document.body).backgroundColor;

  myChart.setTheme(dark ? 'dark' : 'light');
}

That comment is the one thing that caught me out while writing this. getComputedStyle(root).getPropertyValue('--bg') hands back the literal text light-dark(#f7f8f4, #14181a), because a custom property is substituted, not resolved. Read a real property - backgroundColor on an element - and you get rgb(20, 24, 26).

The two static declarations cover the case where nobody has touched the switch, and the browser picks between them itself:

HTML
<meta name="theme-color" content="#f7f8f4" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#14181a" media="(prefers-color-scheme: dark)" />

They know about the operating system but nothing about the manual choice, which is why the one without a media attribute - the one paint() writes - takes over the moment somebody picks a side.

The same three states in a desktop app

In Avalonia the application holds the variant, and Default means "whatever the operating system says":

XML
<Application xmlns="https://github.com/avaloniaui"
             x:Class="MyApp.App"
             RequestedThemeVariant="Default">
</Application>

The three states map straight onto it:

C#
public static ThemeVariant ToVariant(string? stored) => Normalize(stored) switch
{
    Light => ThemeVariant.Light,
    Dark => ThemeVariant.Dark,
    _ => ThemeVariant.Default,
};

/// <summary>Applies the mode to the running application; System hands the choice back to the OS.</summary>
public static void Apply(string? stored)
{
    if (Application.Current is { } app)
        app.RequestedThemeVariant = ToVariant(stored);
}

Store the string in your settings file and call Apply at startup, before the main window is shown. Default keeps following Windows for as long as the app runs, so a machine that goes dark at sunset takes the app with it. If you need to know which variant actually ended up being used - to pick an icon, say - read ActualThemeVariant, not the requested one.

Testing it

Six things, and they take a minute:

  1. Light system, System selected - light page.
  2. Dark system, System selected - dark page.
  3. Pick the opposite of the system. It wins, and a reload keeps it, with no flash on the way.
  4. Back to System - it follows the system again.
  5. Flip the system theme while the page is open. It changes without a reload.
  6. Private window, or storage blocked. Everything still works, it just does not remember.

For step 5 in a browser you do not need to touch the operating system at all: DevTools, Rendering, "Emulate CSS prefers-color-scheme". On Windows, the real thing is one click in Auto Dark Mode, which is what switches my machine at sunrise and sunset anyway.

For step 3, watch the first frame rather than the finished page. The easiest way is to throttle the network to something slow and reload: the flash, if there is one, gets long enough to see.

This site does not have the switch yet - it is light only for now. My desktop apps have had it for a while, and when the site gets it, it will be the code above.