Colour scheme selection in CSS, with a side of JavaScript
📅
The story, so far
Some pages allow the user to switch between colour themes. It was often the case that this was done by using a class in the html
element:
For example:
a { color: #00f; }
html.light a { color: #027cb0; }
html.dark a { color: #8cb4ff; }
Although it is simple enough, it requires JavaScript to change the html
element’s class. Something like:
( function( ) {
const html = document.documentElement;
const htmlClassList = html.classList;
const colourSchemes = [ "light", "dark" ];
const schemeOptions = [ "", ...colourSchemes ];
const toggleButton = document.querySelector( "button.color-scheme-toggle" );
// TODO: Initialise colour scheme class
toggleButton.addEventListener( "click", function( clickEvent ) {
const currentScheme = html.getAttribute( "data-color-scheme" );
const nextSchemeIndex = schemeOptions.indexOf( currentScheme ) + 1;
const nextSchemeIndexInRange = nextSchemeIndex % schemeOptions.length;
const nextScheme = schemeOptions[ nextSchemeIndexInRange ];
html.setAttribute( "data-color-scheme", nextScheme );
htmlClassList.remove( ...colourSchemes );
if ( nextScheme != "" ) {
htmlClassList.add( nextScheme );
}
} );
} )( );
Note that some of this code would require some alterations to work with IE11 or older browsers. Specifically, the use of spread syntax and of multiple parameters in Element.classList.remove()
.
If older browsers are not a concern, there are other currently available resources to improve this functionality.
Enter “new” CSS features
prefers-color-scheme
This CSS media feature detects the visitor’s browser or operating system’s setting.
a { color: #00f;}
@media (prefers-color-scheme: light) {
a { color: #027cb0; }
}
@media (prefers-color-scheme: dark) {
a { color: #8cb4ff; }
}
html.light a { color: #027cb0; }
html.dark a { color: #8cb4ff; }
There are at least three reasons to keep the html.light
and html.dark
rules.
First, prefers-color-scheme
may not be supported by the browser. Second, some privacy settings establish prefers-color-scheme
to an empty or default value (e.g., in Firefox, privacy.resistFingerprinting = true
sets prefers-color-scheme
to light
). Third and last, but perhaps most importantly, if there is a choice it should be visible and actionable (i.e., keep the colour scheme toggle).
Although the CSS takes care of the initial state of the colour scheme, we can set the appropriate class to the visitor’s preference, as marked in the TODO line before the toggle button functionality, using matchMedia
:
if ( window.matchMedia ) {
for ( let c = colourSchemes.length - 1; c >= 0; c = c - 1 ) {
const scheme = colourSchemes[ c ];
if ( !matchMedia( "(prefers-color-scheme: " + scheme + ")" ).matches ) {
continue;
}
html.setAttribute( "data-color-scheme", scheme );
htmlClassList.remove( ...colourSchemes );
htmlClassList.add( scheme );
break;
}
}
While dwelling in JavaScript, it would be simple to use localStorage
to remember the visitor’s choice, but there are more interesting problems to tackle on the side of CSS. The biggest one is repetition.
Looking at the last CSS ruleset, it is easy to see that any rules inside the prefers-color-scheme
media queries are going to have to be duplicated in their respective html.light
or html.dark
rules. That means that changing one value will require to look for its replica. This can get tedious, but another CSS feature can help with that.
Custom properties
Just for convenience, we are going to assume that the default colour scheme has the same values as the light
one. A basic use of CSS custom properties would look something like this:
html {
/* --l- prefix for light, --d- prefix for dark */
--l-interactive-color: #027cb0;
--d-interactive-color: #8cb4ff;
}
a { color: var(--l-interactive-color); }
@media (prefers-color-scheme: light) {
a { color: var(--l-interactive-color); }
}
@media (prefers-color-scheme: dark) {
a { color: var(--d-interactive-color); }
}
html.light a { color: var(--l-interactive-color); }
html.dark a { color: var(--d-interactive-color); }
All the values are defined once and used two or three times, in the case of the dark
and light
values, respectively. Still, the fact that we have repetition is a shortcoming. Let’s see how to avoid this.
Custom properties, abused
The var
function supports an optional second argument for a fallback value. For example:
a:active {
color: var(--l-interactive-active-color, #f0f);
}
The colour of an active link will be #f0f
if --l-interactive-active-color
is invalid. The fallback value can itself be a custom property using the var()
syntax.
There is a way to turn this logic into a flagging system. It is perfectly explained by Lea Verou, and the gist of it is defining a custom property per flag and switching their values between initial
and a white space.
When a custom property is set to initial
it makes var()
use the fallback:
html { --l: initial; }
a {
color: var(--l, #027cb0);
/* will evaluate to */
color: #027cb0;
}
If set to a white space, var()
will leave it as such. The clever bit is using this to “compose” a value conditionally.
Let’s define --l
and --d
, for light and dark colour schemes, respectively. If one is set to initial
the other one must be a white space:
html { --l: initial; --d: ; }
@media (prefers-color-scheme: light) {
html { --l: initial; --d: ; }
}
@media (prefers-color-scheme: dark) {
html { --l: ; --d: initial; }
}
html.light { --l: initial; --d: ; }
html.dark { --l: ; --d: initial; }
Now, this is possible:
a {
color: var(--l, #027cb0) var(--d, #8cb4ff);
}
If --l
is set to initial
(and --d
to a white space), this rule evaluates to:
a {
color: #027cb0 /* The second var() has resulted in a white space */;
}
Conversely, when the value of --d
is initial
(and the value of --l
is a white space), it will be:
a {
color: /* The first var() has resulted in a whitespace */ #8cb4ff;
}
On a property that only takes one value, white spaces after the colon and/or before the semi-colon are ignored.
Putting it all together
html {
/* Colour scheme switches */
--l: initial;
--d: ;
/* Colour values */
--l-interactive-color: #027cb0;
--d-interactive-color: #8cb4ff;
}
/* User preferences */
@media (prefers-color-scheme: light) {
html { --l: initial; --d: ; }
}
@media (prefers-color-scheme: dark) {
html { --l: ; --d: initial; }
}
/* Colour scheme state overrides */
html.light { --l: initial; --d: ; }
html.dark { --l: ; --d: initial; }
a {
color: var(--l, var(--l-interactive-color)) var(--d, var(--d-interactive-color));
}
It is more ingenious than elegant, and far from perfect, but it gets the job done.
A bit more to loose
Prefixes are abbreviated but custom property names can be a bit long. I want my property names to be verbose enough to be easy to understand but, in production, I want the smallest file size possible. Minifiers are great but may not account for CSS custom properties.
Having established that all custom property names start with “--l-
” or “--d-
”, it is easy to create an intermediate file with shorter names to pass to the minifier. For example:
#!/bin/bash
IN_FILE_PATH="original.css";
OUT_FILE_PATH="intermediate.css";
cp -a "$IN_FILE_PATH" "$OUT_FILE_PATH";
CSS_PROP_NAMES=`grep -o -- "--[ld]-[^:^)]*" "$OUT_FILE_PATH" | sort -u`;
CSS_PROP_IDX=0;
for CSS_PROP_NAME in $CSS_PROP_NAMES; do
CSS_VAR_IDX_HEX=$(printf '%x' $CSS_VAR_IDX);
SHORT_PROP_NAME="--v$CSS_VAR_IDX_HEX";
PROP_DEFINITION="$CSS_PROP_NAME\(\s*:[^;]*\);"; # has : and ;
NEW_PROP_DEFINITION="$SHORT_PROP_NAME\1; \/* $CSS_PROP_NAME *\/";
sed "s~$PROP_DEFINITION~$NEW_PROP_DEFINITION~g" -i "$OUT_FILE_PATH";
PROP_USE="$CSS_PROP_NAME\(\s*)\s*\)"; # has ) after
sed "s~$PROP_USE~$SHORT_PROP_NAME\1~g" -i "$OUT_FILE_PATH";
CSS_PROP_IDX=$(($CSS_PROP_IDX+1));
done;
# Pass `intermediate.css` to a minifier.
Granted, the grep
expression may be insufficient in many cases. For example, names such as --l---l-some-name
will not be processed adequately. I could, and probably would, try to improve the name identification and substitution logic. However, what I would definitely do, at much less expense, is define a convenient naming convention. To counter the previous example, names may not have consecutive dashes, except the required two at the beginning.
In this case, a naming convention can reduce the processing effort, besides other benefits such as improving a piece of code’s legibility. On that note, I have to point out that the previous loop preserves the original name of the custom property as a comment. The minifier will discard the comments but it will make the source code more readable. So remember to publish the source maps.