CSS only dark mode - made simple in 2023

Author's picture
Lindus One
Author
Lindus One
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.

Flowchart of possible paths to setting page theme. Arrow icons courtesy of css.gg

Browser preference

prefers-color-scheme: darkno preferenceprefers-color-scheme: light

User selection

Dark theme Light theme

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.

Table of dark/light colors combinations: browser preference[Light-None; Dark] × user selection[None; Dark; Light] = 6 states. Covered with 3 CSS blocks.
Browser preference User
 selection 
Applied CSS Outcome
This chunk of code is applied regardless of browser settings and user choice
/* Preventing uncontrolled body background visibility /
body {margin: 0; padding: 0;}
:root {
/* Theme-agnostic colors. Can be in root scope, constant values. */
  --Link-color: #d65d2d;
  --Gray-universal-dark-light-theme: #666;
}
/* Applying variables with theme-dependant colors */
.SiteHeader, main, .SiteFooter {
  background-color: var(--Background-color-base);
  color: var(--Font-color-base);
  padding: .5rem;
}
/* Theme-dependant switch icons */
.SiteHeader-lightSwitch {
  position: absolute; /* Enable CSS transform to work */
  transform: var(--Sun-icon-display); /* show or hide */
}
.SiteHeader-darkSwitch {
  position: absolute;
  transform: var(--Moon-icon-display);
}
Browser applies this block of CSS and waits for values of theme-dependant variables...
Light or none
/* 1 - Arbitrary browser prference and dark switch NOT checked */
#dark-switch:not(checked) ~ :is(.SiteHeader, main, .SiteFooter) {
  --Moon-icon-display: scale(none); /* show */
  --Sun-icon-display: scale(0); /* hide */
  --Background-color-base: #fff;
  --Font-color-base: #222;
}
            
Show moon hide sun 
Dark text on light background.
Universal gray line

Universal link color

#dark-switch:not(checked) ~ can be omitted, yet claryfies intention.
Light or none
Dark
Light or none
/* 2 - User choice of dark theme */
#dark-switch:checked ~ :is(.SiteHeader, main, .SiteFooter) {
  --Moon-icon-display: scale(0);
  --Sun-icon-display: scale(none);
  --Background-color-base: #202124;
  --Font-color-base: #e6e6e6;
}
Show sun, hide moon  

Bright text on dark background.

Universal gray line


Universal link color

We use .SiteHeader and .SiteFooter class because <header> and <footer> elements may occur more than once on the page and our goal is to apply style only to direct body children.

Dark
/* 3 - Browser dark theme and NO user choice of light theme */
@media (prefers-color-scheme: dark) {
  #light-switch:not(:checked) ~ :is(.SiteHeader, main, .SiteFooter) {
    --Moon-icon-display: scale(0);
    --Sun-icon-display: scale(none);
    --Background-color-base: #202124;
    --Font-color-base: #e6e6e6;
  }
}
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 radio id value. Labels can by located anywhere on the page.
  • Style elements with combination of :checked pseudo-class and general sibling combinator (~).
General sibling combinator (~) in action. Select Light or Dark to outline applied styles.


HTML and CSS structure.

<html> <head> <style> header, main, footer {<!-- Default styles here -->} <!--This branch is applied when Light is checked. --> #light-switch:checked ~ :is(header, main, footer) { background-color: #f0f0f0; color: #000; } <!--This branch is applied when Dark is checked. --> #dark-switch:checked ~ :is(header, main, footer) { background-color: #000; color: #f0f0f0; } </style> </head> <body> <input checked type="radio" id="light-switch" name="color-switch"> <input checked type="radio" id="dark-switch" name="color-switch">
<header> <label for="light-switch">Light</label> <label for="dark-switch">Dark</label> <!-- Header content --> </header> <main><!-- Main body content --></main> <footer><!-- Footer content --></footer>
</body> </html>

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.