rails + tailwind dark mode Stimulus Controller that persists across page loAds

Recently I had reason to add dark mode to a Ruby on Rails 8 app. After a few iterations, I landed on the following implementation which I thought I would share. This implementation persists the dark mode setting across page loads using local storage, so it is pretty ideal for most applications.

I am making the assumption here that you are already a Rails developer and don’t need a lot of this explained to you.

Use this button somewhere on a page that is loaded on every page visit (like a navbar or the footer):

<button data-controller="dark_mode" class="flex flex-col justify-center ml-3">
  <div data-dark-mode-target="toggleButton">
    <label class="relative cursor-pointer p-2" for="light-switch">
        <svg class="dark:hidden" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
            <path class="fill-yellow-300" d="M7 0h2v2H7zM12.88 1.637l1.414 1.415-1.415 1.413-1.413-1.414zM14 7h2v2h-2zM12.95 14.433l-1.414-1.413 1.413-1.415 1.415 1.414zM7 14h2v2H7zM2.98 14.364l-1.413-1.415 1.414-1.414 1.414 1.415zM0 7h2v2H0zM3.05 1.706 4.463 3.12 3.05 4.535 1.636 3.12z" />
            <path class="fill-yellow-300" d="M8 4C5.8 4 4 5.8 4 8s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z" />
        </svg>
        <svg class="hidden dark:block" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
            <path class="fill-yellow-300" d="M6.2 1C3.2 1.8 1 4.6 1 7.9 1 11.8 4.2 15 8.1 15c3.3 0 6-2.2 6.9-5.2C9.7 11.2 4.8 6.3 6.2 1Z" />
            <path class="fill-yellow-300" d="M12.5 5a.625.625 0 0 1-.625-.625 1.252 1.252 0 0 0-1.25-1.25.625.625 0 1 1 0-1.25 1.252 1.252 0 0 0 1.25-1.25.625.625 0 1 1 1.25 0c.001.69.56 1.249 1.25 1.25a.625.625 0 1 1 0 1.25c-.69.001-1.249.56-1.25 1.25A.625.625 0 0 1 12.5 5Z" />
        </svg>
        <span class="sr-only">Switch to light / dark version</span>
    </label>
  </div>
</button>  

Create a Stimulus controller, name it ‘dark_mode_controller.js’, and add this to it:

import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["toggleButton"];
connect() {
// Initialize dark mode based on localStorage on page load
this.initializeDarkMode();
// Add click event listener to toggle button
this.toggleButtonTarget.addEventListener('click', () => this.toggleDarkMode());
}
disconnect() {
// Clean up event listener when controller disconnects
this.toggleButtonTarget.removeEventListener('click', () => this.toggleDarkMode());
}
initializeDarkMode() {
const htmlElement = document.documentElement;
const darkModePreference = localStorage.getItem('darkMode');
if (darkModePreference === 'enabled') {
htmlElement.classList.add('dark');
} else {
htmlElement.classList.remove('dark');
}
// Update button state if needed (assuming you have some visual indicator)
this.updateToggleButtonState();
}
toggleDarkMode() {
const htmlElement = document.documentElement;
const isDarkMode = htmlElement.classList.toggle('dark');
// Save preference to localStorage
localStorage.setItem('darkMode', isDarkMode ? 'enabled' : 'disabled');
// Update button state
this.updateToggleButtonState();
}
updateToggleButtonState() {
const isDarkMode = document.documentElement.classList.contains('dark');
// You can customize this part based on your toggle button's UI
this.toggleButtonTarget.setAttribute('aria-pressed', isDarkMode);
this.toggleButtonTarget.setAttribute('aria-label',
isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'
);
}
}

Reload your page and you should have a dark mode stimulus controller that persists the park mode setting across page loads using local storage.