Nathaniel
Posted on August 3, 2022
It's 2022 and lots of websites support dark mode. Around 7% of websites did in 2021
Many of these websites even have a toggle for changing between light and dark mode.
Dark mode is great, many people prefer it, it's easier on the eyes, and it extends battery life.
But what about other color schemes?
For my website missingdice.com I wanted to support more than two color schemes.
It's a website with tools for playing games — rolling dice, spinning wheels, that sort of thing — it's simple and it's for fun.
The site fulfils quite simple needs, so the same tool might be used for a kid's party, or by a group playing dungeons and dragons, or possibly some low stakes betting.
So i decided to include light
, dark
, party
, vegas
, and dungeons and dragons
color schemes, and a high contrast mode
— useful for some people with visual impairments, sunglasses, or if the screen is in direct sunlight.
I previously shared a tutorial on how to make a html
and css
only dark mode toggle — some found it useful and requested I publish a tutorial for the color-scheme picker. So here it is.
It covers much of the same ground as the dark mode toggle. It works fine without JavaScript
, but also has some progressive enhancements for users with JavaScript
enabled.
Here's what we're going to make…
A color scheme picker that…
- works well without javascript
- defaults to a user's
dark mdoe
preference - supports
windows high-contrast
mode andforced-colors
…as well as some progressive enhancements for users with JavaScript
enabled…
- Saves user preferences
- Updates the
<meta rel="theme"/>
and<body>
tag with the correct color scheme.
…and some graceful degradations for users on older browsers…
- falls back to
light mode
if css variables (custom properties) aren't supported - remove the toggle if css variables aren't supported.
What kind of input to use
Since we have multiple values, and can only select one at a time — we have only two choices. Radio Buttons
or a Select Box
.
Due to our no-js
limitation — we need to use an input who's state can be accessed in CSS.
Select box <option>
elements are nested inside a <select>
element — this makes their state inaccessible to the rest of the page using CSS.
So we're left with radio buttons
— which is a good choice anyway!
How it works
To change our page's color scheme using just css — we need to make sure our radio buttons appear before everything else in our document.
We have a radio input for each of our color schemes. Then we can target the rest of our document with the :checked
css selector.
if the radio input with id=blue
is selected, we use the :checked
css selector to target the rest of our document. The css selector looks like this: #blue:checked ~ *
.
We can't style the <body>
tag based on the radio inputs. So we create a <div>
that our site's content goes inside, then style that <div>
to fill the screen.
This causes a small issue when overscrolling
and on non-rectangular screens, but we'll solve that later with some progressive enhancements.
We can then visually hide the <input>
elements, and use their corresponding labels as buttons to toggle color scheme.
The labels can be placed anywhere on the page and will work fine so long as the label's for
attribute matches it's corresponding radio input's id
.
<body>
<input id="dark" class="color-scheme-button" name="color-scheme" type="radio"/>
<input id="light" class="color-scheme-button" name="color-scheme" type="radio"/>
<input id="blue" class="color-scheme-button" name="color-scheme" type="radio"/>
<!-- more radio buttons go here -->
<div class="color-scheme-wrapper">
<label for="dark">dark mode</label>
<label for="light">light mode</label>
<label for="blue">blue mode</label>
<!-- Site Content -->
</div>
<style>
.color-scheme-wrapper {
min-height:100vh;
background:white;
color:black;
}
.color-scheme-wrapper {
background:white;
color:black;
}
#dark:checked ~ * {
background:black;
color:white;
}
#blue:checked ~ * {
background:blue;
color:white;
}
</style>
</body>
This code works. But we still need to add a few more things. We'll start with CSS variables.
CSS variables (custom properties)
css variables save us a lot of clutter — without them we'd have to restyle every component on our website for each color scheme.
Instead we can define some variables and then change their values based on the selected color scheme.
For the sake of this tutorial we'll keep things simple — one color for the background and one for text.
First we create values for the default color scheme — this will be the light theme:
:root {
--background:white;
--text:black;
}
Then we do the same for the other color schemes — this time applying the variables to every element that comes after the checked radio button that matches that scheme.
If a user checks a radio button, that color schemes' variables are applied to the whole site.
#dark:checked ~ * {
--bg:black;
--text:white;
}
#blue:checked ~ * {
--bg:blue;
--text:white;
}
.color-scheme-wrapper {
background:var(--bg);
color:var(--text);
}
This works — but we need a fallback for browsers that don't support CSS variables — which as of writing is true for ~3.5%
of web users.
css is very forgiving — if a browser comes accross a property it doesn't understand it will ignore it and keep going.
So, we can make our site look good on older browsers by repeating the property twice, once with a fallback color, and then again with our variable, like so:
.color-scheme-wrapper {
background:white;
background:var(--bg);
color:black;
color:var(--text);
}
We can also hide our color scheme buttons for users who's browsers don't support them:
.color-scheme-button,
.color-scheme-button + label {
display:none;
}
@supports(--css:custom-properties) {
.color-scheme-button,
.color-scheme-button + label {
display:block;
}
}
Defaulting to user's preferred color scheme
Our current setup doesn't support preferred color scheme — meaning if a user who's has dark mode enabled on their device visits our site, they'll be met with our light theme.
Using the prefers-color-scheme
css media query — we can make sure our visitor's preferred color scheme is the default.
We do this by "swapping" the variables of the light theme and dark theme if the user's device has dark-mode
enabled — making the default color scheme "dark" and the dark
theme "light".
:root {
--bg:white;
--text:black;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:black;
--text:white;
}
}
#dark:checked ~ * {
--bg:black;
--text:white;
}
@media (prefers-color-scheme: dark) {
#dark:checked ~ * {
--bg:white;
--text:black;
}
}
That's great, now if a user has dark mode enabled, the site will default to dark mode — however, if the user then wants to manually select light mode
they need to select the radio button with the "Dark mode" label.
A small snag, but it has a simple fix.
We can use the same media queries to change the text in the <label>
elements.
We create two <span>
elements with the text "Light mode" and "Dark mode" in the labels — then create classes to display the relevant text based on the user's preferences.
<body>
<div class="color-scheme-wrapper">
<label for="light">
<span class="dark-mode-hide">Light Mode</span>
<span class="light-mode-hide">Dark Mode</span>
</label>
<label for="dark">
<span class="dark-mode-hide">Dark Mode</span>
<span class="light-mode-hide">Light Mode</span>
</label>
</div>
</body>
<style>
.light-mode-hide {
display:none
}
@media (prefers-color-scheme: dark) {
.light-mode-hide {
display:inline
}
.dark-mode-hide {
display:none
}
}
</style>
Now our label's change names to match.
Forced Colors and High Contrast Mode
For a variety of reasons, some users prefer specific color schemes to be applied to every web page they visit.
For these users selecting a color scheme on our website will have no effect.
Using css mediwe can remove the toggle for users with who have enabled either: forced colors
or windows high contrast mode
.
We can also use these css media queries to make other subtle changes to our design.
Windows High Contrast mode
-ms-high-contrast
non-standard (microsoft only) css media feature. Introduced on Internet Explorer 11 on Windows 8 and continues to be supported on Edge.
@media (-ms-high-contrast: active) {
.color-scheme-button,
.color-scheme-button + label {
display:none;
}
}
Forced colors
Forced colors allows users to select their own color scheme for the web. It's often used to create high contrast color schemes, but can be used to make color schemes for other purposes too.
@media (forced-colors: active) {
.color-scheme-button,
.color-scheme-button + label {
display:none;
}
}
Progressive Enhancements
Theme color meta tag
The theme-color
meta tag attribute adds color to the browser's user interface (ui) — usually to the browser toolbar. See examples of theme-color in action here
It looks like this:
<meta name="theme-color" content="white" />
You can also use media queries with these meta tags and have them match the user's preferred color scheme:
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black">
Styling the body tag
In some browsers the color of the <body>
tag will be visible when the user overscrolls or if the screen has large rounded corners — like the new iphones and pixel phones. So we should also style the body tag with our default color schemes:
body {
background:white;
}
@media (prefers-color-scheme: dark) {
background:black;
}
These are great for setting defaults — but when our users change the color scheme the theme-color
meta tags and the body color will stay the same.
To fix this requires JavaScript — so we need to write some code to update the meta tags.
Enhancements with JavaScript
First we'll create an object with keys matching the id
s of the radio buttons — the values are the colors we want to use to set the theme colors.
var themeColors = {
dark: "black",
light: "white",
blue: "blue"
}
We need to access the meta tags and the body in our javascript. To keep our code simple I've given the meta tags ids:
<meta id="light-theme-meta-tag" name="theme-color" media="(prefers-color-scheme: light)" content="white">
<meta id="dark-theme-meta-tag" name="theme-color" media="(prefers-color-scheme: dark)" content="black">
Then we access them like so:
var lightThemeMetaTag = document.getElementById('light-theme-meta-tag')
var darkThemeMetaTag = document.getElementById('dark-theme-meta-tag')
var body = document.body
Then we'll write a function that updates our meta tags and our body color. It takes user selected theme as a parameter.
We'll also add a variable selectedTheme
to store the currently selected theme so it's accessible to other functions — we'll see why shortly
var selectedTheme = 'light'
function updateTheme(theme) {
var color = themeColors[theme]
lightThemeMetaTag.setAttribute("content", color);
darkThemeMetaTag.setAttribute("content", color);
body.style.backgroundColor = color;
selectedTheme = theme
}
Then we’ll listen for changes to our radio inputs.
If a change is detected, we’ll call the updateTheme
function with id
of the selected radio input.
var radios = document.querySelectorAll('input[type=radio][name="color-scheme"]');
radios.forEach(radio => radio.addEventListener('change', (event) => {
var theme = event.target.id
updateTheme(theme)
}));
That updates our meta tags to match the currently selected theme.
There's one small problem though — on dark mode the values for our light and dark theme are the wrong way round!
So we’ll add some code that swaps the colors round if dark mode is enabled. It will also listens in the device's preferred color scheme and update accordingly. For instance, some people have their devices set to shift to dark mode at certain times.
First we'll write a function that swaps the light and dark colors around in our themeColors
object.
function swapLightAndDark(){
var light = themeColors.light
var dark = themeColors.dark
themeColors.dark = light
themeColors.light = dark
}
Then we add code the swaps the themes round if the device is in dark mode — and swaps them again if the user's device changes it's prefered color scheme.
Changes to the prefers-color-scheme
variable also needs to call our setTheme
function to trigger the changes. This is where the selectedTheme
variable comes in handy.
var preferedColorScheme = window.matchMedia('(prefers-color-scheme: dark)');
var dark = preferedColorScheme.matches
if(dark){
swapLightAndDark()
}
preferedColorScheme.addEventListener('change', () => {
swapLightAndDark()
setTheme(selectedTheme)
});
Remembering user preferences
The biggest issue with not using JavaScript is that our user's preferences won't be remembered between visits — or even between pages on our site.
So, for users with JavaScript enabled we'll use local storage
to store their preference.
First we'll write a function that saves the color scheme to local storage. It takes the current theme as a parameter:
function saveThemeToLocalStorage(theme){
if(localStorage){
localStorage.setItem('color-scheme', theme);
}
}
Then another function that checks if the user has a saved color scheme in local storage — if they do we'll update the theme and make sure we set that theme's radio input to checked
.
function getThemeFromLocalStorage(){
if(localStorage){
var savedColorScheme = localStorage.getItem('color-scheme');
if(savedColorScheme){
var radioButton = document.getElementById(savedColorScheme);
radioButton.checked = true;
updateTheme(savedColorScheme)
}
}
}
Putting it all together
Then we can those function in our previously written code. Putting it all together looks like this:
var themeColors = {
dark: "black",
light: "white",
blue: "blue"
}
var lightThemeMetaTag = document.getElementById('light-theme-meta-tag')
var darkThemeMetaTag = document.getElementById('dark-theme-meta-tag')
var body = document.body
var selectedTheme = 'light'
function updateTheme(theme) {
var color = themeColors[theme]
lightThemeMetaTag.setAttribute("content", color);
darkThemeMetaTag.setAttribute("content", color);
body.style.backgroundColor = color;
selectedTheme = theme
saveThemeToLocalStorage(theme)
}
function swapLightAndDark(){
var light = themeColors.light
var dark = themeColors.dark
themeColors.dark = light
themeColors.light = dark
}
function saveThemeToLocalStorage(theme){
if(localStorage){
localStorage.setItem('color-scheme', theme);
}
}
function getThemeFromLocalStorage(){
if(localStorage){
var savedColorScheme = localStorage.getItem('color-scheme');
if(savedColorScheme){
var radioButton = document.getElementById(savedColorScheme);
radioButton.checked = true;
updateTheme(savedColorScheme)
}
}
}
var preferedColorScheme = window.matchMedia('(prefers-color-scheme: dark)');
var dark = preferedColorScheme.matches
if(dark){
swapLightAndDark()
}
preferedColorScheme.addEventListener('change', () => {
swapLightAndDark()
setTheme(selectedTheme)
});
var radios = document.querySelectorAll('input[type=radio][name="color-scheme"]');
radios.forEach(radio => radio.addEventListener('change', (event) => {
var theme = event.target.id
updateTheme(theme)
}));
// This must be called after the check for dark mode
getThemeFromLocalStorage()
Finally, in order to make sure our theme is loaded from local storage before the page renders, we need to place this JavaScript in a tag after the radio inputs, but before the rest of our page.
Ideally this is inlined, to stop the page rendering being delayed by an http requrest.
<body>
<input id="dark" class="color-scheme-button" name="color-scheme" type="radio"/>
<input id="light" class="color-scheme-button" name="color-scheme" type="radio"/>
<input id="blue" class="color-scheme-button" name="color-scheme" type="radio"/>
<script>
<!-- our color scheme script goes here -->
</script>
<div class="color-scheme-wrapper">
<!-- Site Content -->
</div>
That's it.
That's it!. With the above code and some styling you can make a very usable color scheme picker that works for as many people as possible.
I've created a demo in codepen. It uses all the code above, but with some extra styling, and a dropdown menu for selecting the color schemes, here it is:
Some final thoughts
Using a drop down for the color schemes
I explored a number of ways of displaying the color scheme options. But in the end I chose to use a drop down with a standard looking radio button for each option.
This allowed me to display the full labels for each color scheme without taking over the whole screen — as well as a clear label explaining what the buttons do.
I experimented with
- showing a preview of the color scheme next to each option
- styling buttons without text with the color of each theme
- creating a multi-toggle switch looking thing
- having a single button that cycles through each color theme.
I showed these to friends, and they mostly didn’t realize what they we’re for changing the color theme. Showing previes of the color scheme was also hard to decipher, especially if the theme was similar to the current theme.
So, a drop down with clear text labels was the winner.
Saving color schemes without JS!?
This color scheme picker was first created for missingdice.com. It's a project I work on when I don't feel like doing anything high stakes. It has lots of self-imposed rules about how it should work.
One of these rules is that every tool should work without JavaScript
.
For instance, if a user has JavaScript disabled and they use the dice rolling tool. Instead of rolling dice on the client side, it submits a form to a server, which rolls the dice, and sends back a page with the result.
This works great! BUT, it means our lovely no-js color switcher effectively becomes useless. A user picks a color, then they submit the form, and their color preference is gone, the results of their roll are shown in the default mode.
So, how to get round this?
We place our entire page inside a <form>
, and submit our users chosen color scheme along with the options for their dice roll! Then we make sure the server responds with results shown in their preferred color scheme.
I'll be adding that to the site soon, so stay tuned.
Posted on August 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.