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:
: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).: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.: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
clickorchangeevents on a button. - Toggle a class such as
.dark-themeon 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
localStorageor read cookies. If a user refreshes the page, the toggle returns to its default HTML state unless a server-side language sets thecheckedattribute 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.