This post is more than a year old. There is a chance the content is outdated.

Extending Tailwind CSS dark mode to use system preference

Extending Tailwind CSS dark mode to use system preference


In a previous article I showed you how to create a theme switcher using Alpine and Tailwind.

This theme switcher has a default of light and switched to dark but did not take into consideration the user's system preference for theme such as on MacOS like below:

I thought this would be a nice feature-add as I like when websites I visit take my preferences into consideration.

It was pretty easy to implement, I just had to change a few spots.

Previously, this was the code in my master template that was controlling adding the dark class to my body:

1<html
2 x-data="{darkMode: localStorage.getItem('dark') === 'true'}"
3 x-init="$watch('darkMode', val => localStorage.setItem('dark', val))"
4 x-bind:class="{'dark': darkMode}"
5>

After modifications for taking the user's system preference into consideration it looks like this:

1<html
2 x-data="{currentTheme: localStorage.getItem('theme') || localStorage.setItem('theme', 'system')}"
3 x-init="$watch('currentTheme', val => localStorage.setItem('theme', val))"
4 x-bind:class="{'dark': currentTheme === 'dark' || (currentTheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)}"
5>

So basically the changes are:

  1. Instead of the darkMode variable (which is now called currentTheme) being based solely off of the dark local storage item, it is based off of the theme local storage item and if that doesn't exist it is being defaulted to system.
  2. Instead of adding the dark class to the html tag using the darkMode boolean, it adds it if the currentTheme is dark or if the currentTheme is system and the user prefers a dark theme.

So that takes care of adding the class to the document, but what about the theme switcher.

This was the theme switcher before:

1<button x-cloak x-on:click="darkMode = !darkMode;">
2 <x-heroicon-s-moon x-show="!darkMode" class="p-2 ml-3 w-8 h-8 text-gray-700 bg-gray-100 rounded-md transition cursor-pointer hover:bg-gray-200" />
3 <x-heroicon-s-sun x-show="darkMode" class="p-2 ml-3 w-8 h-8 text-gray-100 bg-gray-700 rounded-md transition cursor-pointer dark:hover:bg-gray-600" />
4</button>

And this is the theme switcher after modification for system preferences:

1<div x-cloak class="flex items-center">
2 <button x-show="currentTheme === 'dark'" x-on:click="currentTheme = 'system'">
3 <x-heroicon-s-moon class="p-2 ml-3 w-8 h-8 text-gray-100 bg-gray-700 rounded-md transition cursor-pointer dark:hover:bg-gray-600" />
4 </button>
5 
6 <button x-show="currentTheme === 'light'" x-on:click="currentTheme = 'dark'">
7 <x-heroicon-s-sun class="p-2 ml-3 w-8 h-8 text-gray-700 bg-gray-100 rounded-md transition cursor-pointer hover:bg-gray-200" />
8 </button>
9 
10 <button x-show="currentTheme === 'system'" x-on:click="window.matchMedia('(prefers-color-scheme: dark)').matches ? currentTheme = 'light' : currentTheme = 'dark'">
11 <x-heroicon-s-cog x-show="! window.matchMedia('(prefers-color-scheme: dark)').matches" class="p-2 ml-3 w-8 h-8 text-gray-700 bg-gray-100 rounded-md transition cursor-pointer hover:bg-gray-200" />
12 <x-heroicon-s-cog x-show="window.matchMedia('(prefers-color-scheme: dark)').matches" class="p-2 ml-3 w-8 h-8 text-gray-100 bg-gray-700 rounded-md transition cursor-pointer dark:hover:bg-gray-600" />
13 </button>
14</div>

This ended up being a lot more in terms of code, but functionality is pretty simple:

  1. Instead of using 1 button and switching the icon, we use 3 different buttons for clarity.
  2. We show the buttons based on the currentTheme
  3. If the currentTheme is system, we change the colors of the icons to match the current system theme.

The last thing to do was change which logos were being shown based on the theme.

The original code looked like this:

1<img x-cloak src="{{ asset('img/logo.svg') }}" :class="{'hidden': darkMode}" {{ $attributes }} alt="{{ appName() }}" />
2<img x-cloak src="{{ asset('img/logo-white.svg') }}" :class="{'hidden': !darkMode}" {{ $attributes }} alt="{{ appName() }}" />

And the updated code looked like this:

1<img x-cloak src="{{ asset('img/logo.svg') }}" :class="{'hidden': currentTheme === 'dark' || (currentTheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)}" {{ $attributes }} alt="{{ appName() }}" />
2<img x-cloak src="{{ asset('img/logo-white.svg') }}" :class="{'hidden': currentTheme === 'light' || (currentTheme === 'system' && ! window.matchMedia('(prefers-color-scheme: dark)').matches)}" {{ $attributes }} alt="{{ appName() }}" />

Nearly the same, with a few changes:

  1. Instead of choosing the logo based on the darkMode variable we base it on the currentTheme variable.
  2. If currentTheme is dark or system and the user prefers dark, then we show the white logo.
  3. If the currentTheme is light or system and the user prefers light, then we show the dark logo.

That's pretty much it, now we have full support for the user's system theme, and they can choose to override it if they see fit.

Hope you learned something. See you on the next one.

Anthony Rappa

By Anthony Rappa

Hello! I'm a full stack developer from Long Island, New York. Working mainly with Laravel, Tailwind, Livewire, and Alpine.js (TALL Stack). I share everything I know about these tools and more, as well as any useful resources I find from the community. You can find me on GitHub and LinkedIn.