Since this afternoon this blog has a way to change between dark and light themes.
I made this because my eyes hurt when I visit really light websites from a window with a dark background. My desktop environment is configured to show everything with a dark background and I spend most of my time on the terminal so my eyes get used to the dark background and the light ones hurt, specially at night.
I realized one of the sites that made my eyes hurt was my own website and I can’t fix the whole web, but at least I can fix my site and write down what I did to encourage you to fix yours.
User preference
First things first, since 2019 CSS has a new mediaquery that lets you know if the visitor has a dark or a light background configured in their system. I was introduced to this thanks to @sheenobu, who took the time to answer to my message and make all this happen1.
Here you have some documentation about that mediaquery
called prefers-color-scheme
, but in summary it can take three values
—light
, dark
and no-preference
— that are quite self-explanatory in my opinion.
So if you mix that with a little bit of CSS custom properties magic (AKA variables) you can just parametrize the whole color scheme and then use the mediaquery to choose the variables you want to use. That’s fine.
Locked in your preference
The problem comes when you want to be able to let the user change from one color scheme to other.
The prefers-color-scheme
mediaquery gets user’s preference but, at least in
Firefox2, it’s not easy for the user to change to a light theme if they want.
They are locked in what they chose for their OS.
Sometimes it’s interesting to let the readers change the theme by themselves for multiple reasons. As each developer or designer chooses the colors they want, that may led to color scheme inconsistencies between the system colors and the sites or have readability issues. Also, readability is subject to personal preference: I like to use dark backgrounds, but sometimes I prefer to read from a lighter background if the ambient light is stronger.
Letting visitors contradict themselves
In order to let the visitors go against that blood pact they signed with their OS, we need some JavaScript3.
Note about my personal taste: I avoid the use of JavaScript in places that is not needed. I consider it unnecessary for blogs or websites that show you information in a format supported by the web (text, audio, video…). Also, I consider really important to think about the users who don’t want to or can’t run JavaScript.
Most of my sites don’t use JavaScript at all, modern CSS and HTML are more than enough for most of the applications. Webpages with a heavy use of JavaScript are a threat to accessibility and make bots, spiders and non-canonical browsers hard to implement4.
This blog makes use of JavaScript for two different things:
- The theme change I’m talking about in this post
- Source code highlighting
In both cases the blog is prepared to work perfectly for users with JavaScript disabled. When the JavaScript is disabled, code blocks respect the HTML tags for code declaration but they have no any extra tags or style. In the case of the theme control, when JavaScript is disabled, the website makes use of the user’s default preference leaving the option to change the theme in hands of the browser or the operating system. Most of the time, these design decisions work in favour of users that access the web from browsers that don’t need any kind of styling (terminal browsers, screen readers…) helping the browser find the content more easily.
When I was going to start implementing it I remembered a Medium post by Marcin Wichary that explains the process very well. I used as a reference but I added a couple of points I want to share with you. I’ll also try to cover everything the author talks about with my own words, just in case someone doesn’t want to access Medium5.
First difference from the reference post is what I told you about in the
previous section. The post is from 2018, and the prefers-color-scheme
mediaquery is from 2019, so it’s not mentioned in the post6.
The mentioned post has also an introduction to CSS Custom Properties and their use. I already gave you a link to the MDN Web documentation and I don’t feel myself informed enough to try to explain you anything about CSS, so better go there and read.
That said, the first point we have to solve is to have some property that makes
CSS know which theme is in use. That can be implemented like the article does,
adding a data-
something attribute to the html
that then can be captured
in CSS like this:
html[data-theme='dark'] {
/*Your dark theme style here*/
}
html[data-theme='light'] {
/*Your light theme style here*/
}
WARNING: Be careful with the priority of this change, you have to put it after the
prefers-color-scheme
mediaquery to make the cascade work as it should. If you put it before, the mediaquery is going to override this configuration and will make it pass unnoticed.
But now you have to deal with the attribute and make it change whenever the visitor selects one or other configuration. As I said, you need JavaScript for that. Setting the attribute is as simple as this vanilla JavaScript line:
document.documentElement.setAttribute('data-theme', color);
Good. Now it’s quite easy to start, right? Add a button, put an event listener on it and whenever it’s pressed change the theme by setting the attribute with the line I just show you. For instance:
var theme_switch = document.getElementById('dark-light-switch');
function change(color){
document.documentElement.setAttribute('data-theme', color);
theme_switch.setAttribute('color', color);
}
function theme_change_requested(){
color = theme_switch.getAttribute('color');
if(color=='light')
change('dark');
else
change('light');
}
theme_switch.addEventListener('click', theme_change_requested);
We selected an element that will act as a theme switcher and added an event
listener to it. Whenever it’s clicked it will run the theme_change_requested
function that will change the color from the current to the other. Easy.
Problems come now.
Get the initial color
In order to start that process, you have to be able to know the current theme
in use, that way you’d be able to activate the necessary attribute for the
html
tag or the current look of the theme switcher (in this blog a sun or a moon).
This current theme inspection results to be difficult because JavaScript
doesn’t have access to the prefers-color-scheme
mediaquery. You can bypass
that by getting something you know is going to be present in your CSS and
reading it. In my case I used the background-color
of the body
because I
set the background to white in the light color scheme as you can see in the
getCurrentColor
function:
var theme_switch = document.getElementById('dark-light-switch');
function change(color){
document.documentElement.setAttribute('data-theme', color);
theme_switch.setAttribute('color', color);
}
function theme_change_requested(){
color = theme_switch.getAttribute('color');
if(color=='light')
change('dark');
else
change('light');
}
function getCurrentColor(){
// This is dependant of the CSS, be careful
var body = document.getElementsByTagName('BODY')[0];
var background = getComputedStyle(body).getPropertyValue('background-color');
if(background == 'rgb(255, 255, 255)') {
return 'light';
} else {
return 'dark';
}
}
function init( color ){
change(color);
theme_switch.setAttribute('color', color);
}
init( getCurrentColor() )
theme_switch.addEventListener('click', theme_change_requested);
Now, with this new code you are able to get the current theme when the page
loads and prepare your button and your html
tag to start with the color
scheme the visitor has configured by default.
Page-change amnesia
Once you have all we explained working you’ll realize the website forgets visitor’s decision when they navigate form one page to another. It makes perfect sense, because there’s no way to keep the selection set.
We can make use of localStorage
for this. With the following line we can set
the 'color'
item in the localStorage
to the color visitor chose:
localStorage.setItem('color', color);
Updating the getCurrentColor
function, we can get the color from the
localStorage
first, and, if it’s not set, we can use the strategy we used
before with body
‘s background-color
. This is the updated getCurrentColor
function:
function getCurrentColor(){
// Color was set before in localStorage
var storage_color = localStorage.getItem('color');
if(storage_color !== null){
return storage_color;
}
// If local storage is not set check the background of the page
// This is dependant of the CSS, be careful
var background = getComputedStyle(body).getPropertyValue('background-color');
if(background == 'rgb(255, 255, 255)') {
return 'light';
} else {
return 'dark';
}
}
With this function we can know what color has the user configured or the color
they chose in our color selector button, but still have to activate the theme
if the user has chosen one that is not the one on their preferences. Updating
the init
and change
functions this way is more than enough for that:
function init( color ){
change(color, true);
localStorage.setItem('color', color); // CHANGED!
theme_switch.setAttribute('color', color);
}
function change(color, nowait){
document.documentElement.setAttribute('data-theme', color);
theme_switch.setAttribute('color', color);
localStorage.setItem('color', color); // CHANGED!
}
Smooth transitions
In the article I had as a reference the author made a simple but very effective approach for theme transitions. The article uses the following CSS for smooth transitions:
html.color-theme-in-transition,
html.color-theme-in-transition *,
html.color-theme-in-transition *:before,
html.color-theme-in-transition *:after {
transition: all 750ms !important;
transition-delay: 0 !important;
}
The article also explains how to activate the transition, the following piece of JavaScript code activates the transition and deactivates it one second later:
window.setTimeout(function() {
document.documentElement.classList.remove('color-theme-in-transition')
}, 1000)
document.documentElement.classList.add('color-theme-in-transition');
We have to be careful with where do we add this because we may be forcing
transitions in the navigation and that’s really annoying. Updating the change
function with the transition is not enough, we need a way to discard the
transition for the changes produced by the init
function. We can exploit the
fact that JS arguments are optional for that. Of course, the transition must be
added in the change
function.
function init( color ){
change(color, true); // Add true for nowait
localStorage.setItem('color', color);
theme_switch.setAttribute('color', color);
}
function change(color, nowait){ // Add the nowait argument
// Discard transition is nowait is set
if(nowait !== true){
window.setTimeout(function() {
document.documentElement.classList.remove('color-theme-in-transition')
}, 1000)
document.documentElement.classList.add('color-theme-in-transition');
}
document.documentElement.setAttribute('data-theme', color);
theme_switch.setAttribute('color', color);
localStorage.setItem('color', color);
}
Now with all this we are able to make the website the user configuration from one page to another.
Wrapping up
With this configuration we are able to:
- Get visitor’s configuration based on the OS color theme: dark or light.
- Make the visitor able to change their mind by choosing a different color scheme.
- Get the initial color of the page to be able to initialize the buttons.
- Make the web remember the color scheme selection from one page to another
using
localStorage
. - Add smooth transitions but don’t activate them in page changes to avoid weird flashings.
And that’s all.
No! It isn’t! We also had some fun talking about philosophy, accessibility and sites you shouldn’t visit. In fact, all the color theme stuff was an excuse to talk about it, but sssssssh don’t tell anyone.
If after knowing that you are still interested on the excuse itself, all the code together it looks like this:
var theme_switch = document.getElementById('dark-light-switch');
var body = document.getElementsByTagName('BODY')[0];
function init( color ){
change(color, true);
localStorage.setItem('color', color);
theme_switch.setAttribute('color', color);
}
function change(color, nowait){
// Discard transition is nowait is set
if(nowait !== true){
window.setTimeout(function() {
document.documentElement.classList.remove('color-theme-in-transition')
}, 1000)
document.documentElement.classList.add('color-theme-in-transition');
}
document.documentElement.setAttribute('data-theme', color);
theme_switch.setAttribute('color', color);
localStorage.setItem('color', color);
}
function theme_change_requested(){
color = theme_switch.getAttribute('color');
if(color=='light')
change('dark');
else
change('light');
}
function getCurrentColor(){
// Color was set before in localStorage
var storage_color = localStorage.getItem('color');
if(storage_color !== null){
return storage_color;
}
// If local storage is not set check the background of the page
// This is dependant of the CSS, be careful
var background = getComputedStyle(body).getPropertyValue('background-color');
if(background == 'rgb(255, 255, 255)') {
return 'light';
} else {
return 'dark';
}
}
init( getCurrentColor() )
theme_switch.addEventListener('click', theme_change_requested);
-
Thanks for being there! ↩
-
The way to change that is to access
about:config
and update theui.systemUsesDarkTheme
field:1
meanstrue
and0
meansfalse
. Be careful because it’s not a boolean field, it’s an integer field (I don’t know why, don’t ask me). This change affects to all the tabs. ↩ -
This isn’t true in every context. We need it here because this is a Static Website. This means the content you read is already created at server side before you ask for it. If it wasn’t, all this could be simpler: just load a different CSS depending on the user’s choice. The static counterpart of this approach would be to create the whole website once per color scheme and leave them in different folders like
domain/dark/whatever.html
anddomain/light/whatever.html
this is not practical at all and carries tons of extra problems. ↩ -
As everyone want to have a good rank in the search engines more than anything else, Google made a lot of decisions about how websites should be in order to be able to be indexed properly. With the market quota they had (almost 100%) they had the power to force developers and designers make websites the way Google liked. That was obviously bad but it had some good consequences: websites were easy to scrape or read by a robot with low resources (that was what Google wanted). But since some years ago Google announced their spider is able to run JavaScript, that made all those developers and designers who wanted to make their websites have a good ranking free: they don’t have any other limit to the use of JavaScript right now (because they don’t really care about anything else). That made many pages impossible to read by clients that don’t use JavaScript and made the process of accessing websites automatically or with non-canonical browsers impossible in many cases. Thank you developers and designers for breaking the Web. ↩
-
There are so many reasons to avoid Medium that someone made a specific website for them. Also, some interesting free software projects decided to migrate away from it and wrote about it, that’s the case of ElementaryOS. I asked in the fediverse about this and many people sent me articles and links. Thanks to all! ↩
-
Too bad Marcin, you were unable to see the future. ↩