Skip to main content
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Johnson Tai
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Johnson Tai

I Finally Implemented Dark Mode Using CSS Custom Properties On This Blog

By
Published in , Comments (1)

For years, I've wanted to implement Dark Mode on this blog. I started playing around with component theming using CSS Custom Properties in Angular 3-years ago; and, I added a very rudimentary dark mode to BigSexyPoems; but, I could never quite wrap my head around theming on a larger scale. But finally, this past week, I completed the first pass on a dark mode implementation for BenNadel.com! It's not entirely complete; and, it's not the greatest choice of colors; but, it's a major step in the right direction.

Dark mode on BenNadel.com

I implemented dark mode in a few steps using a certain set of constraints. As such, this approach may not work for everyone. But, I thought it might be worth sharing:

  • I switched my CSS compilation from the less CSS CLI over to the Parcel build tool (though still using LESS CSS as my CSS language of choice).

  • Once I had Parcel in place, I then enabled its PostCSS plugin so that I could further enable CSS autoprefixer (unrelated to theming) and the postcss-custom-properties plugin. This would allow any use of CSS custom properties to have a fall-back definition in IE11 (yes, I still get a bit of traffic from IE11).

  • I then created a theme LESS CSS file to outline the light and dark themes.

  • I replaced the majority of color, background-color, and border-color properties with CSS custom properties.

  • And, finally, I added the ability to manually toggle the theming above and beyond the CSS media query that matches your system preferences.

Parcel is a pretty cool build tool. It took me some trial-and-error and a bit of Googling to get it working. But, once things started to click, it's been pretty smooth sailing since. What's really cool is that it seems to pick configurations up automatically and will install dev-dependencies (node modules) automatically as needed.

So, when I added postcss-custom-properties to my .postcssrc file, it automatically installed the necessary modules at the start of the next build:

{
	"plugins": {
		"autoprefixer": {
			"flexbox": true
		},
		"postcss-custom-properties": true
	}
}

The postcss-custom-properties plugin works by inspecting and then transforming the post-build, generated CSS file. It looks specifically for CSS custom properties that are defined in a :root{} block. It then takes those variables and updates the CSS file to include fall-backs to hard-coded color values.

So, for example, if I had a CSS file that contained the following:

:root {
	--color: #333333 ;
}

body {
	color: var( --color ) ;
}

... the PostCSS plugin will duplicate the color property such that it uses the hard-coded value first followed by the custom property:

:root {
	--color: #333333 ;
}

body {
	color: #333333 ; // Will be overridden by next property.
	color: var( --color ) ;
}

This way, in older browsers that don't support CSS custom properties, the hard-coded color will be used and the custom property will be ignored. That's the beauty of CSS - it just ignores stuff it doesn't understand! If we use the :root block to define the "light mode", then any browser that doesn't support CSS custom properties will default, for all intents and purposes, to the light mode.

Once I had Parcel and the PostCSS plugins working, I created a theme.less file to define my Light and Dark modes. This was probably the hardest part of the whole endeavor. Whereas before my CSS definitions were all just willy-nilly, with theming I have to choose actual, semantic names for the styling I am trying to accomplish.

Naming things is hard.

Choosing the right abstraction is hard.

I started by trying to very loosely base my color variables on Google's Material Design color scheme. Material Design uses semantics like "Primary", "Surface", and "Error". On top of that, it then uses a shading system where you can go lighter and darker on each property. To be honest, I'm having a lot of trouble wrapping my head around Material Design since I've never used it hands-on.

What I came up with was developed very iteratively. Meaning, I started with some CSS custom property definitions in my :root. Then, as I was replacing those values into my CSS class definitions, I would discover outlier colors. These outlier colors would then be added back to :root and another round of find-and-replaced would occur. Repeat. Repeat. Repeat.

As of this morning, here's what I have in my theme.less file. You'll see that my Light and Dark themes are defined first as LESS CSS mixins. This way, I can duplicate the light and dark definitions into a few different CSS selectors. I needed one set of selectors based on CSS media queries; and, another set of selectors based on explicit HTML attributes. This way, I could support both inherent system preferences as well as explicit override preferences.

.light-theme() {
	--primary: #e80049 ;
	--primary-darker: #e10046 ;
	--on-primary: #ffffff ;

	--secondary: #227fbb ;
	--secondary-darker: #103f5d ;
	--on-secondary: #ffffff ;

	--background: #ffffff ;
	--on-background-lightest: #999999 ;
	--on-background-lighter: #666666 ;
	--on-background: #333333 ;
	--on-background-darker: #121212 ;

	--surface-lighter: #fafafa ;
	--surface: #eaeaea ;
	--on-surface-lighter: #666666 ;
	--on-surface: #333333 ;
	--on-surface-darker: #000000 ;

	--error: #cc554e ;
	--error-darker: #c15049 ;
	--on-error: #ffffff ;

	--highlight: #ffdc73 ;
	--on-highlight: #222222 ;
	--on-highlight-shadow: #ffefb6 ;

	--inline-code: #ffdf69 ;
	--block-code: #f5f5f5 ;
	--on-inline-code: #000000 ;
	--on-block-code: #000000 ;

	--input: #ffffff ;
	--on-input: #000000 ;
	--on-input-shadow: #cccccc ;

	--border-lightest: #eaeaea ;
	--border-lighter: #dadada ;
	--border: #cccccc ;
	--border-darker: #cccccc ;
	--border-darkest: #aaaaaa ;
}

.dark-theme() {
	--background: #121212 ;
	--on-background-darker: #cccccc ;
	--on-background-lightest: #999999 ;
	--on-background-lighter: #999999 ;
	--on-background: #bbbbbb ;

	--surface-lighter: #222222 ;
	--surface: #333333 ;
	--on-surface-lighter: #999999 ;
	--on-surface: #bbbbbb ;
	--on-surface-darker: #cccccc ;

	--on-error: #bbbbbb ;

	--highlight: #ffdc73 ;
	--on-highlight: #222222 ;
	--on-highlight-shadow: transparent ;

	--block-code: var( --surface ) ;
	--on-block-code: var( --on-surface ) ;

	--input: #222222 ;
	--on-input: #cccccc ;
	--on-input-shadow: transparent ;

	--border-lightest: #333333 ;
	--border-lighter: #444444 ;
	--border: #666666 ;
	--border-darker: #666666 ;
	--border-darkest: #666666 ;
}

// The PostCSS custom properties plugin will use this :root block as the source
// for all the fall-back colors when it duplicates CSS properties so that this
// all continues to work in IE11.
:root {
	color-scheme: light dark ;

	.light-theme() ;
}

// First, let's setup the system preference support using media queries.
@media ( prefers-color-scheme: light ) {
	:root {
		.light-theme() ;
	}
}

@media ( prefers-color-scheme: dark ) {
	:root {
		.dark-theme() ;
	}
}

// Then, let's override the system preferences based on explicit selection.
html[ data-theme = 'light' ] {
	.light-theme() ;
}

html[ data-theme = 'dark' ] {
	.dark-theme() ;
}

Again, I was loosely basing this on Google's Material Design; but, I was then just making it up as I went. As you can see, a lot of these definitions included a background color followed by an --on- foreground color.

As I was building-out my theme file, I was simultaneously looking through my existing LESS CSS definitions and replacing hard-coded colors with CSS custom properties. For example, here's my body definition:

body {
	background-color: var( --background ) ;
	color: var( --on-background ) ;
	.font-family-content ;
	font-size: 1.2rem ;
	font-weight: 400 ;
	line-height: 1.5 ;
	margin: 0px 0px 0px 0px ;
	padding: 0px 0px 0px 0px ;
}

If I stopped right now, what I have would give me Light and Dark modes that match the user's system preferences. But, I also wanted the user to be able to explicitly override the system preferences for the site. Mostly, this would make it easier for me to tweak the settings over time without having to flip my system preferences back-and-forth.

ASIDE: This would also open up the ability to auto-choose a theme that matches the user's time-of-day; a feature that I found very compelling on Alligator.io.

First, I went into my layout file and I added Light and Dark buttons:

<div aria-hidden="true" class="m-theme-toggle">
	<button data-theme="light" class="m-theme-toggle__button">
		Light
	</button>
	<button data-theme="dark" class="m-theme-toggle__button">
		Dark
	</button>
</div>

By default, these toggle-buttons are hidden. They are only activated if my <html> element has an explicit theme applied to it:

.m-theme-toggle {
	position: absolute ;
	right: 15px ;
	top: 10px ;

	&__button {
		background-color: transparent ;
		border: none ;
		color: var( --on-background ) ;
		display: none ;
		font-size: 1.2rem ;
		font-weight: 400 ;
		line-height: 1.5 ;
		margin: 0px 0px 0px 0px ;
		padding: 0px 0px 0px 0px ;
		text-decoration: underline ;
	}

	// Only show the button that will toggle to the OTHER theme.
	html[ data-theme = "light" ] &__button[ data-theme = "dark" ],
	html[ data-theme = "dark" ] &__button[ data-theme = "light" ] {
		display: block ;
	}
}

Notice that the buttons are only shown if a data-theme attribute is on the <html> element. This theme attribute is only applied via JavaScript; which means that if JavaScript is disabled, the buttons will remain hidden and only the implicit system preferences will be applied via the CSS media query blocks show before.

If JavaScript is enabled, however, I apply the data-theme attribute right in the <head> of the page before any content is rendered:

<head>
	<!-- .... truncated for demo .... -->
	<script type="text/javascript">
		(function() {

			// If there's no LocalStorage API, then we're going to lean entirely on
			// the media-queries in the CSS to drive the theming. We only want to
			// show the controls if the user can persist the preference across page
			// requests.
			if ( ! window.localStorage || ! window.matchMedia ) {

				return;

			}

			var prefersTheme = window.matchMedia( "( prefers-color-scheme: dark )" ).matches
				? "dark"
				: "light"
			;

			document.documentElement.setAttribute(
				"data-theme",
				( window.localStorage.getItem( "theme" ) || prefersTheme )
			);

		})();
	</script>
</head>

Here, we're checking to see if the MediaMatch and LocalStorage APIs are available. We need these to be able to detect the default theme and then to persist explicit preferences, respectively. I start by trying to pull a theme selection out of the LocalStorage API; and, if it's null, I fall-back to using the media-query-based preference.

The block above is what applies the data-theme attribute to the document. It's what causes the toggle buttons to become visible to the user. I then have to wire-up the click-handlers for those toggle buttons:

// Setup the site theme interactivity.
function initTheme( $, win, doc ) {

	var prefersDark = window.matchMedia( "( prefers-color-scheme: dark )" )
		.matches
	;
	var prefersLight = ! prefersDark;

	var html = $( "html" );
	var controls = $( ".m-theme-toggle" )
		.on( "click", "button", handleClick )
	;

	// ---
	// PRIVATE METHODS.
	// ---

	function handleClick( event ) {

		var target = $( event.target );
		var theme = target.data( "theme" );

		html.attr( "data-theme", theme );

		// When the user toggles the theme, we want to, if possible, UNSET the persisted
		// value so that we can keep the theme in alignment with the system preferences.
		// So, if the user is toggling the theme back to one that matches the current
		// system preferences, then we want REMOVE the storage item so that any system-
		// level changes will be reflected implicitly in the future.
		if (
			( ( theme === "light" ) && prefersLight ) ||
			( ( theme === "dark" ) && prefersDark )
			) {

			localStorage.removeItem( "theme" );

		} else {

			localStorage.setItem( "theme", theme );

		}

	}

}

Primarily, this jQuery code is installing event-delegation on the toggle widget which captures click events and then updates the data-theme attribute. But, look closely at the click-handler - it's not so simple: it either persists or deletes the explicit preference based on the system preference.

This is more of a judgement call than anything else; but, if a user toggles the explicit theme back to one that matches their system preference, I'm not persisting the selection - I'm deleting it. The rationale here being that if the user realigns the theme to match their system preferences, I believe that what they're really are trying to do unset the previous selection. This way, if they then subsequently change their system preferences, this site will be primed to change along with them (instead of sticking to the old theme).

Toggling BenNadel.com back-and-forth between Light and Dark mode.

Anyway, that's what I've got so far. It's definitely a work-in-progress. But, at least my Light and Dark modes are basically working. This is all part of the modernization effort of this blog, bring the ColdFusion server-side code and the client-side code into the 21st century!

Epilogue on GitHub Gists

A decade ago, I started using GitHub gists to color-code my code samples (the code snippets are actually part of the content but then are replaced by Gists on page-load). Right now, gists are always Light themed. But, I've seen some examples of creating Dark themes for gists. That's on my radar - I just haven't had time to look into it yet.

Want to use code from this post? Check out the license.

Reader Comments

15,902 Comments

For the time-being, it looks like I can use a CSS filter to convert the GitHub Gists to dark-mode:

@media ( prefers-color-scheme: dark ) {
	.gist {
		filter: invert( 1 ) ;
	}
}

html[ data-theme = 'dark' ] {
	.gist {
		filter: invert( 1 ) ;
	}
}

This will invert the color-space for the gist container when in dark mode. It actually works quite well.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel