A CSS-First Approach to Color Scheme Picking

Supporting a user-selectable color scheme usually means wiring up a script to toggle classes or attributes on the page root. With the arrival of :has() in major browsers, much of that work can now be done declaratively. Pairing that selector with native form controls covers the interaction layer, and a small amount of JavaScript handles what CSS cannot: remembering the choice across page loads.

Start With System Preferences

A robust implementation begins by honoring the operating system's setting. The light scheme is the default; dark styles are applied only when the user's system preference calls for it. Use the prefers-color-scheme media feature to detect that:

selector {
  /* light styles */

  @media (prefers-color-scheme: dark) {
    /* dark styles */
  }
}

Alongside the media query, set the color-scheme property so the browser applies its own dark UI chrome — default background, text, scrollbars, and form controls. CSS variables keep the value easy to switch later, though plain keywords work just as well:

:root {
  /* light styles here */
  color-scheme: var(--color-scheme, light);
  
  /* system preference is "dark" */
  @media (prefers-color-scheme: dark) {
    --color-scheme: dark;
    /* any additional dark styles here */
  }
}

Let Users Override the System

To give visitors control at the page level, present a control such as a <select> menu with options for following the system, forcing light, or forcing dark. A group of radio inputs is another viable pattern, but a dropdown keeps the surface compact:

<select id="color-scheme">
  <option value="system" selected>System</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>

Previously, responding to that selection meant attaching an event listener that would flip a class or attribute on <html> or <body>. With :has(), the CSS can react to the control's state directly. The selector targets the page root only when it contains a <select> whose checked <option> carries a particular value:

:root:has(select option[value="dark"]:checked)

Given that light is the default, the CSS needs to handle two dark scenarios: the page preference is “System” and the system prefers dark; or the page preference is explicitly “Dark.” For the first case, wrap the system-level dark media query in the :has() check for the “System” option:

:root {
  /* light styles here */
  color-scheme: var(--color-scheme, light);
    
  /* page preference is "system", and system preference is "dark" */
  @media (prefers-color-scheme: dark) {
    &:has(#color-scheme option[value="system"]:checked) {
      --color-scheme: dark;
      /* any additional dark styles, again */
    }
  }
}

The example above uses CSS Nesting, which is broadly supported across current browsers. The extra indentation is optional; the same rules can be written with fully qualified selectors if you need to support older Android browsers.

:root {
  /* light styles */
  color-scheme: var(--color-scheme, light);
    
  /* page preference is "dark" */
  &:has(#color-scheme option[value="dark"]:checked) {
    --color-scheme: dark;
    /* any additional dark styles */
  }
}

The second scenario uses a nearly identical :has() condition, this time matching the “Dark” option rather than “System”:

:root {
  /* light styles */
  color-scheme: var(--color-scheme, light);
    
  /* page preference is "dark" */
  &:has(#color-scheme option[value="dark"]:checked) {
    --color-scheme: dark;
    /* any additional dark styles */
  }
    
  /* page preference is "system", and system preference is "dark" */
  @media (prefers-color-scheme: dark) {
    &:has(#color-scheme option[value="system"]:checked) {
      --color-scheme: dark;
      /* any additional dark styles, again */
    }
  }
}

The page now responds to both the user's operating system setting and the in-page control, with no JavaScript required for the actual switching.

Transition With Care

An abrupt color change can feel jarring. Adding a transition to the :root smooths the switch, though note that the color-scheme property itself is not animatable — only the CSS custom properties and other explicitly styled values will transition:

:root {
  transition-duration: 200ms;
  transition-property: /* properties changed by your light/dark styles */;
}

Not every visitor will appreciate animation. Respect the prefers-reduced-motion media feature by removing the transition duration for those users:

:root {
  transition-duration: 200ms;
  transition-property: /* properties changed by your light/dark styles */;
    
  @media screen and (prefers-reduced-motion: reduce) {
    transition-duration: none;
  }
}

Devices with slow paint rates, such as e-ink screens, can also suffer from transitions. The update media feature with a value of slow lets you disable the transition there as well:

:root {
  transition-duration: 200ms;
  transition-property: /* properties changed by your light/dark styles */;
    
  @media screen and (prefers-reduced-motion: reduce), (update: slow) {
    transition-duration: 0s;
  }
}

Here is a working demo. It intentionally styles only the properties that should animate, working around the lack of color-scheme transitions:

See the Pen [CSS-only theme switcher (requires :has()) [forked]](https://codepen.io/smashingmag/pen/YzMVQja) by Henry.

See the Pen CSS-only theme switcher (requires :has()) [forked] by Henry.

Persist the Choice

One problem remains: refreshing the page or navigating to another route resets the <select> to its default. Users expect their selection to stick. A small amount of JavaScript restores the state from localStorage on each load, and stores it again whenever the user changes the menu:

/*
 * If a color scheme preference was previously stored,
 * select the corresponding option in the color scheme preference UI
 * unless it is already selected.
 */
function restoreColorSchemePreference() {
  const colorScheme = localStorage.getItem(colorSchemeStorageItemName);

  if (!colorScheme) {
    // There is no stored preference to restore
    return;
  }

  const option = colorSchemeSelectorEl.querySelector(`[value=${colorScheme}]`);  

  if (!option) {
    // The stored preference has no corresponding option in the UI.
    localStorage.removeItem(colorSchemeStorageItemName);
    return;
  }

  if (option.selected) {  
    // The stored preference's corresponding menu option is already selected
    return;
  }

  option.selected = true;
}

/*
 * Store an event target's value in localStorage under colorSchemeStorageItemName
 */
function storeColorSchemePreference({ target }) {
  const colorScheme = target.querySelector(":checked").value;
  localStorage.setItem(colorSchemeStorageItemName, colorScheme);
}

// The name under which the user's color scheme preference will be stored.
const colorSchemeStorageItemName = "preferredColorScheme";

// The color scheme preference front-end UI.
const colorSchemeSelectorEl = document.querySelector("#color-scheme");

if (colorSchemeSelectorEl) {
  restoreColorSchemePreference();

  // When the user changes their color scheme preference via the UI,
  // store the new preference.
  colorSchemeSelectorEl.addEventListener("input", storeColorSchemePreference);
}

The following demo includes that persistence logic. Change the scheme, refresh, and the choice holds — though note that if your system preference differs from the stored page preference, you may briefly see the system scheme before the saved one applies, depending on when the script runs relative to other page resources:

See the Pen [CSS-only theme switcher (requires :has()) with JS persistence [forked]](https://codepen.io/smashingmag/pen/GRLmEXX) by Henry.

See the Pen CSS-only theme switcher (requires :has()) with JS persistence [forked] by Henry.

Dealing With Older Browsers

Since :has() is now available in all current browser releases, leaning on it is reasonable. If legacy browser support matters, decide whether color scheme choice itself is a progressive enhancement. If so, hide the selection UI entirely when :has() is missing:

@supports not selector(:has(body)) {
  @media (prefers-color-scheme: dark) {
    :root {
      /* dark styles here */
    }
  }

  #color-scheme {
    display: none;
  }
}

Otherwise, revert to the traditional JavaScript event listener that toggles a class or attribute on the document root. That approach also requires the persistence script to handle the core interaction, not just state restoration. For additional legacy-friendly patterns, the CSS-Tricks guide to dark mode covers several alternatives.

Smashing Editorial