Pure CSS Theme Toggle with :root:has(:checked)

Modern CSS allows developers to implement full-featured, global theme toggles—such as switching between light and dark modes—without relying on JavaScript. By pairing the :has() relational pseudo-class with the :checked state of an HTML form control, styles defined at the highest level of the document tree can react dynamically to user input anywhere on the page. This article explains how the :root:has(:checked) selector works, how it manipulates global CSS custom properties, and how to implement it cleanly.

The Underlying Mechanism

The selector :root:has(:checked) combines three distinct CSS concepts:

  1. :root: Targets the top-level element of the document, which is the <html> element in HTML documents. This is the conventional scope for defining global design tokens via CSS custom properties (variables).
  2. :has(): Known as the relational pseudo-class or "parent selector." It determines whether an element contains or is followed by descendants that match a specific selector.
  3. :checked: A state pseudo-class that matches radio buttons, checkboxes, or <option> elements currently selected by the user.

When combined, :root:has(:checked) evaluates whether the root element contains any checked input. If a user clicks an <input type="checkbox"> inside the document body, the :has() condition on the :root resolves to true, instantly applying whichever rules are nested within that block to the entire document.

Basic Implementation

To implement this pattern, create an accessible toggle using a standard checkbox and label, then map the document's design tokens to its state.

1. The HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <label for="theme-toggle" class="theme-switch">
      <input type="checkbox" id="theme-toggle" class="sr-only">
      <span class="slider">Toggle Theme</span>
    </label>
  </header>
  <main>
    <h1>Pure CSS Theming</h1>
    <p>This layout updates globally without any JavaScript.</p>
  </main>
</body>
</html>

2. The CSS Rules

/* Default Theme (Light Mode) */
:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --accent-color: #0066cc;
}

/* Active State (Dark Mode) */
:root:has(#theme-toggle:checked) {
  --bg-color: #121212;
  --text-color: #f0f0f0;
  --accent-color: #4da3ff;
}

/* Global Styles */
body {
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s ease, color 0.3s ease;
  font-family: sans-serif;
}

/* Visually hide the actual checkbox while keeping it accessible */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

Why It Eliminates JavaScript

Traditionally, theme switching requires JavaScript to:

  • Listen for click or change events on a button.
  • Toggle a class such as .dark-theme on the <html> or <body> element.
  • Coordinate accessibility attributes like aria-pressed.

The :root:has() approach bypasses the event-handling requirement. The browser handles the input's toggle state natively, and CSS responds automatically by checking for the state within the document tree.

Targeting an ID specifically—such as :root:has(#theme-toggle:checked)—prevents unintentional style updates if other forms or inputs exist on the same page.

Handling Multiple Themes with Radio Buttons

The pattern also extends to multiple themes (e.g., Light, Dark, High Contrast) by swapping the single checkbox for a group of radio inputs:

:root:has(#theme-light:checked) {
  --bg-color: #ffffff;
  --text-color: #111111;
}

:root:has(#theme-dark:checked) {
  --bg-color: #1e1e1e;
  --text-color: #ffffff;
}

:root:has(#theme-contrast:checked) {
  --bg-color: #000000;
  --text-color: #ffff00;
}

Key Considerations

While the :root:has(:checked) pattern significantly simplifies front-end architecture, consider the following trade-offs:

  • State Persistence: Pure CSS cannot write to localStorage or read cookies. If a user refreshes the page, the toggle returns to its default HTML state unless a server-side language sets the checked attribute based on a stored cookie.
  • System Preferences: To ensure a complete implementation, use the @media (prefers-color-scheme: dark) media query to set baseline styles before applying the :has() overrides, ensuring users without explicit selections still receive their preferred OS theme.