CSS only dark mode - made simple in 2023
- Publication date:
- Last update:
A short guide about building dark/light mode (theme) comprising prefers-color-scheme
property and the ability for the user to manually override. Properly structured HTML, CSS custom properties in respective scope, two radio buttons, SVG icons, and general sibling combinator will do the job! This is CSS-only approach.
A couple of remarks
Mode and theme are interchangeable names for a set of colors on a page. The CSS class naming convention is my preference, rather than a recommendation. This is work in progress; essential HTML and CSS with comments is TL;DR
. As of the end of 2022 it’s not yet possible to reliably control <body>
background with ‘checkbox hack’ and :has()
pseudo-class (variables are out of scope). Make sure the <header>
, <main>
and <footer>
take up all visual space. A simple reset <style>body {margin:0; padding:0;}</style>
is sufficient.
Browser preference and user choice
The CSS prefers-color-scheme
property combined with <input type=radio>
allows the webpage to opt into the theme-specific defaults and gives visitors the power to override. After the page gets loaded with the browser preference, users can change the color scheme.
Main ingredients:
- Theme dependant colors - one set for dark, one set for light;
- Theme agnostic colors - these look good on dark and light;
- Structured HTML - all content inside
<header>
,<main>
and<footer>
; - CSS Custom properties declared in proper scope -
<header>
,<main>
and<footer>
; prefers-color-scheme
- browser decision based on settings;- Two radio buttons - empower user to change after page gets loaded;
- Two labels with SVG icons: sun, moon - a ‘click’ handler for sighted visitor;
:checked
hack to swap colors - ‘dynamic’ CSS with general sibling combinator (~);
Essential HTML and CSS to build theme switch
The next two sections provide our solution in a nutshell. A detailed description of the respective concepts will follow in the subsequent parts.
HTML structure
Nothing fancy here, just a typical structure of a page - <header>
, <main>
, <footer>
. All visible content must be their descendant. By placing radio buttons before these HTML elements we can manipulate values of CSS variables used by them. Radio buttons must have an equal name attribute value, which makes them a mutually exclusive pair. In addition it’s possible to navigate using arrow keys.
Properly structured html markup to control CSS variables and maintain accessibility
<body>
<!-- Radio buttons before content HTML. Hidden from a sighted user -->
<input type="radio" id="light-switch" name="dark-light-switch" class="u-visuallyHidden"
aria-labelledby="light-mode-icon-title" value="Enable light mode">
<input type="radio" id="dark-switch" name="dark-light-switch" class="u-visuallyHidden"
aria-labelledby="dark-mode-icon-title" value="Enable dark mode">
<header class="SiteHeader">
<!-- Visual representation of radio buttons for a sighted user -->
<label for="light-switch" class="SiteHeader-lightSwitch">
<svg role="img" width="30" height="30" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- title element in conjunction with aria-labelledby on radio for accessibility:
1. 'tooltip' popup when the mouse is over,
2. screen readers read (usually) when the mouse is over,
3. screen readers read content when radio gets "keyboard focus" (aria-labelledby involved) -->
<title id="light-mode-icon-title">Enable light mode</title>
<!-- SVG icon path -->
</svg>
</label>
<label for="dark-switch" class="SiteHeader-darkSwitch">
<svg role="img" width="30" height="30" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter">
<title id="dark-mode-icon-title">Enable dark mode</title>
<!-- SVG icon path -->
</svg>
</label>
<!-- Header content -->
</header>
<main>
<!-- Main body content -->
</main>
<footer class="SiteFooter">
<!-- Footer content -->
</footer>
</body>
Swaping colors with :checked
and CSS custom properties
Three CSS blocks to cover all combinations of browser and user preferences.
Browser preference | User selection |
Applied CSS | Outcome |
---|---|---|---|
This chunk of code is applied regardless of browser settings and user choice |
|
Browser applies this block of CSS and waits for values of theme-dependant variables... | |
Light or none |
|
|
Show moon hide
Dark text on light background.Universal gray line #dark-switch:not(checked) ~ can be omitted, yet claryfies intention.
|
Light or none |
|
||
Dark |
|
||
Light or none |
|
|
Show sun, hide
Bright text on dark background. Universal gray line We use |
Dark |
|
|
|
Dark |
|
The following sections addresses related concepts in more details.
:checked
and ~
to change styles without javascript (aka CSS Checkbox Hack) in details
A technique known as ‘CSS Checkbox Hack’ allows to control styles of subsequent sibling elements. You create branches of CSS and activate one of them when checkbox, radio button or option is :checked
. It’s a way to emulate click events without javascript. You might commonly see the hack used for toggling visibility of tabs, dropdown menu, and now switch between light and dark mode.
Necessary steps:
- Input elements of type checkbox, radio or option placed before elements to control. In our example:
<header>
,<main>
,<footer>
. All page content go there. - Associate Radio buttons or checkboxes with corresponding labels by setting its
for
attribute equal to radioid
value. Labels can by located anywhere on the page. - Style elements with combination of
:checked
pseudo-class and general sibling combinator (~).
General sibling combinator (~) limitations
The ‘~’ only matches siblings after the <input>
element in document tree. It cannot control parents or siblings that appear before <input>
element. In our case it’s the first and second child of <body>
, so we can change CSS for all content inside <body>
. The <label>
element, which serves as a visual representation for sighted users, can be placed anywhere. However, the CSS background-color
of the <body>
cannot be controlled using this method.