Some really good ways to switch themes
It is nice to have a site look just the way you want it, but even nicer is to have a site that looks how a user wants. With how easy it is to implement a light and dark theme these days, I find it annoying when sites do not give me this choice. As a primarily dark theme user, nothing is better than a sudden 100% bright white site to irk me.
Let's make our site default to the users default theme choice and give the option to use an alternate theme if they wish. More choices is more better.
> TL;DR Jump to the final product to skip all the process and explanations.
The way we used to do it
The first way we were able to achieve this was through media queries.
@media (prefers-color-scheme: light) {
body {
color: black;
background: white;
}
}
@media (prefers-color-scheme: dark) {
body {
color: white;
background: black;
}
}
While solid, and supporting very old browsers, there are better ways with modern CSS.
A better modern way
Of course we can and should use CSS variables. Setup our colours in the root and reuse them throughout the rest of our styles. But I want to talk about some newer functions available to us, light-dark. This function was base lined in 2024 and allows us to directly set colours based on the current color-scheme.
light-dark takes the pattern of two colours, the first being the colour to use when on a light theme and the second being, you guessed it, the colour to use on a dark theme.
By setting the color-scheme to light dark we defer to the users default colour scheme set in their OS.
:root {
/* Tell the browser we support light and dark themes */
color-scheme: light dark;
/* Set out colours as variables for reuse */
--text-color: light-dark(black, white);
--background-color: light-dark(white, black);
}
body {
/* Apply the colours as we need */
color: var(--text-color);
background-color: var(--background-color);
}
This is great, declarative, gives the user a great default theme.
Let's give the user a choice. Maybe they cannot or don't want to change their OS colour scheme. Maybe they are outside or late at night. Sensible defaults are good, but sensible defaults with choice is even better.
Switching natively without javascript
The css selector :has() is magnificent here. In the past we would need to use some javascript to update a data property or css class on the body element when the user toggled a checkbox or pressed a button. Now, thanks to :has() we can query a deeply nested checkbox and keep everything native with no javascript, semantically as well.
Rather than thinking in terms of a checkbox toggles dark mode on, it is better to think of it as the checkbox toggles the alternate theme. A light color-scheme uses with alternate to dark and vice-versa. This allows us to default to the users default colour choice without having to set an initial state of the checkbox with javascript.
:root {
/* Tell the browser to use the dark color-scheme */
color-scheme: dark;
&:has(#alt-theme:checked) {
/* Unless the user overrides this choice */
color-scheme: light;
}
@media (prefers-color-scheme: light) {
/* If the users OS is set to light theme, reverse the above */
color-scheme: light;
&:has(#alt-theme:checked) {
color-scheme: dark;
}
}
--text-color: light-dark(black, white);
--background-color: light-dark(white, black);
/* Assign color and background on
appropriate elements as before */
}
<label for="alt-theme">Toggle theme</label>
<input id="alt-theme" type="checkbox">
Of course we can style the checkbox up nicely and accessibly. This site uses this example.
<div>
<label id="theme" for="alt-theme" tabindex="0">
<span id="theme-light" title="Switch to light theme">
<!-- A light theme icon -->
<span class="visually-hidden">Switch to light theme</span>
</span>
<span id="theme-dark" title="Switch to dark theme">
<!-- A dark theme icon -->
<span class="visually-hidden">Switch to dark theme</span>
</span>
</label>
<input id="alt-theme" type="checkbox">
</div>
:root {
color-scheme: dark;
/* Use for @container queries */
--color-scheme: dark;
/* Fallback for Firefox and Safari */
--cs-picker-dark: none;
--cs-picker-light: flex;
&:has(#alt-theme:checked),
&[data-alt-theme='true'] {
color-scheme: light;
--color-scheme: light;
--cs-picker-dark: flex;
--cs-picker-light: none;
}
@media (prefers-color-scheme: light) {
color-scheme: light;
--color-scheme: light;
--cs-picker-dark: flex;
--cs-picker-light: none;
&:has(#alt-theme:checked),
&[data-alt-theme='true'] {
color-scheme: dark;
--color-scheme: dark;
--cs-picker-dark: none;
--cs-picker-light: flex;
}
}
#theme-dark {
display: flex;
/* Fallback for Firefox and Safari */
display: var(--cs-picker-dark);
/* Modern container query - Only in Chromium browsers */
@container style(--color-scheme: dark) {
display: none;
}
}
#theme-light {
display: flex;
display: var(--cs-picker-light);
@container style(--color-scheme: light) {
display: none;
}
}
#theme-dark, #theme-light {
cursor: pointer;
}
#alt-theme {
/* Hide the checkbox */
display: none;
}
#theme svg {
width: 32px;
height: 32px;
}
I do wish there was a way to query the colour theme and set something besides the colour as with light-dark. The next best thing it to use a @container style query. This allows us to use a new variable --color-scheme to show and hide the right state of our theme switcher.
Unfortunately, even though this is a base line feature, Firefox and Safari do not support style queries on @container and so we must resort to using some more variables in our initial media query. Hopefully in the future we can just query the theme directly, but until then we can use the @container style query with a variable fallback.
Storing the users choice
Next we should store the users choice, otherwise we will return to the default on every navigation. I like to use local storage for this as it is simple, works and doesn't carry the baggage of cookies.
All of this will degrade nicely for users who have disabled Javascipt. The user will get their default theme and the ability to alternate it, and without Javascript they only lose the ability to save that state on navigation.
const isStoredAltTheme = localStorage.getItem('altTheme') === 'true';
const checkboxAltTheme = document.getElementById('alt-theme');
checkboxAltTheme.checked = isStoredAltTheme;
checkboxAltTheme.addEventListener('change', (e) => {
if (e.target.checked) {
localStorage.setItem('altTheme', 'true');
} else {
localStorage.removeItem('altTheme');
}
});
Prevent Flash Of Unstyled Content (FOUC)
The above works and stores the desired theme when the user refreshes or navigates to another page. But you will also notice a flash of the default theme. While small, this is potentially a flashbang for dark mode users and annoying for light mode users. Let's address this Flash of Unstyle Content by setting the desired color-scheme before the page renders.
We do this by running a very small amount of js in the head before the page has finished loading. Generally we avoid running JS in the head to prevent page blocking, but this situation warrants it as it is small, fast and needs to run before the page is first rendered.
We could add a css class for the alt theme but I find it easier to work with data attributes in this case as they are easier to clean up.
/* JS to run in the head before the page loads */
const isStoredAltTheme = localStorage.getItem('altTheme') === 'true';
if (isStoredAltTheme) {
document.documentElement.dataset.altTheme = true;
}
/* JS to run after the body and the page is rendered */
const dataset = document.documentElement.dataset;
const isAltTheme = !!dataset.altTheme;
const checkboxAltTheme = document.getElementById('alt-theme');
checkboxAltTheme.checked = isAltTheme;
/* Delete the data based theming after the first render so the checkbox can take over */
delete dataset.altTheme;
checkboxAltTheme.addEventListener('change', (e) => {
if (e.target.checked) {
localStorage.setItem('altTheme', 'true');
} else {
localStorage.removeItem('altTheme');
}
});
:root {
color-scheme: dark;
&:has(#alt-theme:checked),
&[data-alt-theme='true'] {
color-scheme: light;
}
@media (prefers-color-scheme: light) {
color-scheme: light;
&:has(#alt-theme:checked),
&[data-alt-theme='true'] {
color-scheme: dark;
}
}
--text-color: light-dark(black, white);
--background-color: light-dark(white, black);
/* Assign color and background on
appropriate elements as before */
}
The final product
Here is the final product where it:
- Defaults to the users OS theme
- Allows choosing an alternate theme
- Saves the choice for navigation
- Does not have a Flash Of Unstyled Content on navigation
- Doesn't need Javascript to control theme, only to remember choice
Please let me know if you found this approach useful or if you have other ways of going about theme switching. We should always be ready to learn from others.
-
← Previous
The future of the Senior Developer in an AI world -
Next →
The details element