Dreaming of UI Theming

Published March 30, 2021

Another announcement-style post: the theming on this site has been improved. There are now 4 discrete themes to choose from along with just letting the prefers-color-scheme setting cascade.

The four themes:

Technically, there’s a fifth theme—System—which uses your preference set in prefers-color-scheme to toggle the dark theme if appropriate. If you were not previously specifying the dark theme then you will be using prefers-color-scheme implicitly with this new change.

Curious how I did it? Read on.


High-level overview: using the browser localStorage to persist the theme in a key of the same name. On page load, localStorage is queried for the theme key and if it exists, it’s added to the html element with a data attribute e.g., <html data-theme="dark">. This JavaScript is executed in the <head> right before the stylesheet is <link>ed which prevents the dreaded flash of incorrect theme” (FOIT). The themes themselves are implemented with CSS custom properties AKA variables scoped to the html element.

Now, some code snippets.

localStorage query and set html attribute in <head>:

(function (html) {
  if (localStorage.getItem('theme')) {
    html.setAttribute('data-theme', localStorage.getItem('theme'))

Theme (light & dark) implementations in CSS:

html[data-theme="light"] {
  --accent: #6d14e9;
  --bg: rgb(252, 250, 253);
  --fg: rgb(16, 18, 22);
  --hover-bg: rgba(0,0,0,0.02);

html[data-theme="dark"] {
  --accent: #fff91a;
  --bg: rgb(16, 18, 22);
  --fg: rgb(252, 250, 253);
  --hover-bg: rgba(255, 255, 255, .02);

I also implemented dark mode images. I opted to apply the dark mode styling when the image is :not() :hovered or zoomed:

@media (prefers-color-scheme: dark) {
  /* ... */
  html:not([data-theme="light"]):not([data-theme="deloma"]) img:not(.zoom-img):not(:hover) { filter: brightness(.8) contrast(1.2); }
  /* ... */

html[data-theme="dark"] img:not(.zoom-img):not(:hover),
html[data-theme="amoled"] img:not(.zoom-img):not(:hover) { filter: brightness(.8) contrast(1.2); }

Pain Points

The two primary papercuts I faced were CSS specificity and trying to keep the stylesheet as DRY as possible.

CSS specificity isn’t a new papercut. It’s a common side effect encountered: entire naming conventions like BEM exist to proactively mitigate it. Annoyingly, this presented in the edge case of using a non-dark theme e.g., light or deloma while the prefers-color-scheme queried to dark. My solution of explicitly negating non-dark data-theme attributes is ultimately technical debt cruft.

If I used a CSS preprocessor like SCSS I would have been able to use some of the language features like variables, mixins, and functions to help skirt around the specificity and DRYness. At the very least, it’s another layer of abstraction. And, it would have added complexity in the form of a build step since I would need to compile the style.scss into style.css. If I were to go down the path of entirely rewriting my blog theme I could see the value in using a preprocessor from the beginning. However, for this bolt-on” theming implementation I’ll count the vanilla CSS as a win.

Finally, a saving grace would have been Firefox 87 which lets you toggle prefers-color-scheme in the devtools. Absolutely beats digging through the OS settings to toggle it. FF 87 was released while I began writing this post—better late than never.

As linked in previous posts, the blog theme is open-source. The pull request specifically may be of interest.

  1. Techincally, it’s just the reversed string. I did consider inverting the characters e.g., ROT-13 but it was too gibberish-y↩︎

Last modified March 29, 2021  #announcement   #frontend   #ui   #css   #js 

🔗 Backlinks

← Newer post  •  Older post →