With the recent migration to Astro, I’ve been working on updating the theming to use new CSS techniques, avoid flash-of-unstyled-content, and handle OS-level dark/light preferences. It’s been a fun journey, and I’ve learned a lot about modern CSS and JavaScript. Here’s a quick overview of what I’ve done so far.

What I wanted

Sometimes red things are grey!

  1. Define themes using only CSS, using JavaScript only to switch between them.
  2. Respect the user’s system-level preferences, then site preferences if set.
  3. Save the user’s preference so it persists whenever they visit the site.
  4. Avoid flash-of-unstyled-content (FOUC) when restoring the saved theme.

CSS Improvements

After 8 years, it was time to update the theming to use modern CSS techniques. Since the original post, CSS has evolved significantly, and I wanted to take advantage of new features that allow me to write primarily in CSS instead of maintaining styles also in JavaScript.

Broadly, I’ve moved the previous configuration from JavaScript to CSS, using CSS custom properties to define the theme’s colors. This allows me to change the theme by updating a single CSS file, rather than modifying JavaScript and CSS.

/* Light theme */
html,
html[data-theme='light'] {
  --main-color: #1b70de;
  ...
}
 
/* Dark theme */
html[data-theme='dark'] {
  --main-color: #7aa6ff;
  ...
}

You’ll notice that I use html[data-theme='light'] and html[data-theme='dark'] to define the theme’s colors. This is how I’ll switch between themes using JavaScript. Instead of updating the CSS properties directly, I’ll update the data-theme attribute on the html element and let CSS handle the rest.

color-scheme

Introduced in 2022, the color-scheme property allows you to specify the color scheme for the page. Which in turn the browser can use to adjust the page’s appearance based on the user’s preferences. Things like scrollbars, form controls, and other elements can be styled based on how this property is set.

In my case, this meant setting the color-scheme to light or dark to indicate which version each theme should use.

/* Light theme */
html,
html[data-theme='light'] {
  color-scheme: light;
  ...
}
 
/* Dark theme */
html[data-theme='dark'] {
  color-scheme: dark;
  ...
}

System Preferences

To respect the user’s system-level preferences, I use the prefers-color-scheme media query. This query allows me to detect if the user prefers a light or dark color scheme and adjust the site’s theme accordingly. With the following code, if the user prefers a dark color scheme, the site will automatically switch to the dark theme, even if they haven’t set a preference.

@media (prefers-color-scheme: dark) {
  /* Avoid styling when a preference has been set */
  html:not([data-theme]) {
    color-scheme: dark;
    --main-color: #7aa6ff;
  }
}

By default, the site will use the light theme as we styled html to use the light color scheme. If the user prefers a dark color scheme, the site will automatically switch to the dark theme.

Spiderman pointing

While there is a bit of duplication between the prefers-color-scheme and the html[data-theme='dark'] styles, it’s a small price to pay and can be easily managed with a CSS preprocessor.

JavaScript Improvements

There is still some JavaScript involved, but it’s minimal compared to the previous implementation. The JavaScript is responsible for:

  • Switching between themes
  • Saving the user’s preference

prefers-color-scheme is a great feature that can also be queried from JavaScript. This allows me to detect the user’s system-level preference and adjust the site’s theme programatically.

window.matchMedia('(prefers-color-scheme: dark)').matches //=> true

Flash of Unstyled Content (FOUC)

Normally, best practices for loading JavaScript are to defer loading it until after the page has loaded. However, this can lead to a flash of unstyled content (FOUC) when the user’s preference is restored. To avoid this, I load the script at the end of the body, after the page has loaded.

First we need to check what the theme should be:

const theme = (() => {
  const cachedTheme = localStorage.getItem('theme')
  if (cachedTheme) return cachedTheme
 
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  return prefersDark ? 'dark' : 'light'
})()

Then, we set the theme on the html element using the dataset API to allow us to avoid any FOUC:

document.documentElement.dataset['theme'] = theme

No more flash of unstyled content! 🎉

Switching Themes

The code to switch between themes didn’t change that much. I still use the same logic to determine the current state. This time however, I don’t need to manually update each of the custom properties that needs to change. Instead, I update the data-theme attribute on the html element by using a click event listener on the theme switcher icon.

const handleToggleClick = () => {
  const currentThemeIsDark = document.documentElement.dataset.theme === 'dark'
  document.documentElement.dataset.theme = currentThemeIsDark ? 'light' : 'dark'
 
  const isDark = document.documentElement.dataset.theme === 'dark'
  localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
 
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('theme-switcher').addEventListener('click', handleToggleClick)
})

Tada

End of Story

It’s been a fun journey that allowed me to delete more code and improve the site’s performance. I’m excited to see how the site evolves over the next few years as new CSS and JavaScript features are introduced. If you have any questions or suggestions, feel free to reach out.