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.