galdecoa.com

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.