Light/Dark Mode in Rails Using Stimulus and Tailwind

Over 80% of developers prefer dark interfaces, and studies show that dark mode can reduce eye strain by up to 60% in low-light conditions. Yet many Rails applications still lack proper dark mode support, often due to complex CSS management or poor user experience during theme transitions.

Building dark mode features used to mean wrestling with CSS variables, managing multiple stylesheets, or dealing with flickering pages. As Rails developers, we want solutions that are elegant, maintainable, and don’t break when someone refreshes the page at 2 AM. Beyond developer preferences, dark mode serves users with photosensitivity conditions and provides significant battery savings on OLED devices — making it both an accessibility and user experience enhancement.

After implementing dark mode across several Rails applications at Unagi, I’ve found that combining Tailwind CSS with Stimulus creates a surprisingly smooth experience. The key lies in leveraging Tailwind’s built-in dark mode utilities alongside Stimulus’s reactive controllers to handle state management and persistence. Let me show you how to build this step by step.

Animation of a UI switching from light mode to dark mode, showing the system’s theme changing in real time

Setting Up Tailwind’s Dark Mode

First, we need to configure Tailwind to detect dark mode using CSS classes:

// tailwind.config.js

module.exports = {
  content: [
    './app/views/**/*.{erb,haml,html,slim}',
    './app/javascript/**/*.js',
    './app/helpers/**/*.rb'
  ],
  darkMode: ['class', '[data-theme="dark"]'],
  theme: {
    extend: {
      colors: {
        // Custom colors that work well in both themes
        'primary': {
          50: '#eff6ff',
          500: '#3b82f6',
          900: '#1e3a8a'
        }
      }
    }
  },
  plugins: []
}

The darkMode: ['class', '[data-theme="dark"]'] setting tells Tailwind to apply dark styles when a parent element has the dark class or the data-theme attribute. This gives us complete control over when dark mode activates.

Creating the Stimulus Controller

Now, we’ll build a Stimulus controller that will handle theme switching and persistence. The controller will accomplish three primary functions: first, it will respect the user’s system preferences on the initial visit; second, it will remember the user’s choice in localStorage for future visits; and third, it will immediately update the DOM when the user manually toggles themes.

<!-- /views/layouts/application.html.erb -->

<!DOCTYPE html>
<!-- Connect the stimulus controller -->
<html lang="en" data-controller="theme-switcher" data-theme="dark">
  ...
</html>
// /javascript/controllers/theme_switcher_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["toggler"]
  #theme;

  initialize() {
    if (localStorage.theme === 'dark' || 
        (!('theme' in localStorage) && 
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      this.#theme = 'dark'
    } else {
      this.#theme = 'light'
    }
    this.#setTheme()
  }

  togglerTargetConnected() {
    this.togglerTarget.checked = this.#theme === 'dark'
  }

  toggle() {
    this.#theme = this.#theme === 'dark' ? 'light' : 'dark'
    this.#setTheme()
  }

  #setTheme() {
    this.element.dataset.theme = this.#theme
    
    if (this.#theme === 'dark') {
      this.element.classList.add('dark')
    } else {
      this.element.classList.remove('dark')
    }
    
    localStorage.theme = this.#theme
  }
}

Preventing Theme Flash

One common issue with dark mode implementations is the flash of wrong-colored content before JavaScript loads. We can prevent this with a small script in the document head:

<!-- /views/layouts/_theme_check.html.erb -->
<script>
  if (localStorage.theme === 'light' ||
      (!('theme' in localStorage) &&
       window.matchMedia('(prefers-color-scheme: light)').matches)) {
    document.documentElement.dataset.theme = 'light'
  }
</script>

This inline script duplicates the theme detection logic from our Stimulus controller but runs synchronously in the document head, ensuring users never see a flash of the wrong theme — even on slow connections.

Wiring Everything Together

In your main layout include the theme prevention script:

<!-- /views/layouts/application.html.erb -->

<!DOCTYPE html>
<html lang="en" data-controller="theme-switcher" class="dark" data-theme="dark">
  <head>
    <!-- Meta tags & other head info -->
    <meta name="color-scheme" content="dark light">
    <meta name="theme-color" content="#f2f2f2" media="(prefers-color-scheme: light)">
    <meta name="theme-color" content="#252931" media="(prefers-color-scheme: dark)">

    <%= render "layouts/theme_check" %>
  </head>

  <body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
    <!-- Your app content -->
    
    <!-- Theme toggle button -->
    <button data-theme-switcher-target="toggler" 
            data-action="click->theme-switcher#toggle"
            class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700">
      Toggle Theme
    </button>
  </body>
</html>

Writing Theme-Aware Styles

With everything connected, you can now use Tailwind’s dark mode utilities throughout your application:

<!-- Example component with dark mode styles -->
<div class="bg-white dark:bg-gray-800 shadow-lg dark:shadow-gray-900/20 rounded-lg p-6">
  <h2 class="text-gray-900 dark:text-white text-xl font-semibold mb-4">
    Card Title
  </h2>
  <p class="text-gray-600 dark:text-gray-300">
    This content automatically adapts to the current theme.
  </p>
  <button class="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded">
    Action Button
  </button>
</div>

The beauty of this approach is that every component becomes theme-aware simply by adding the appropriate dark: prefixes to your existing classes.

Making It Robust

For production applications, you’ll want to add a few enhancements to handle edge cases and improve the user experience.

Add smooth transitions between themes
Adds smooth transitions for background and text colors when switching themes. The preload class disables animations while the page is still loading.

body {
  transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
}

.preload * {
  transition: none !important;
}


Handle Turbo navigation properly
Uses Turbo events to add or remove the preload class at the right time. This prevents theme transition animations from triggering incorrectly during navigation.

<!-- filepath: app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html lang="en" data-controller="theme-switcher" class="dark">
  <head>
    <!-- ... existing head content ... -->
  </head>

  <body class="preload bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
    <!-- Remove preload class after page loads -->
    <script>
      document.addEventListener('DOMContentLoaded', () => {
        document.body.classList.remove('preload')
      })
      
      // Re-add preload class during Turbo visits
      document.addEventListener('turbo:visit', () => {
        document.body.classList.add('preload')
      })
      
      document.addEventListener('turbo:load', () => {
        document.body.classList.remove('preload')
      })
    </script>
    
    <!-- Your app content -->
  </body>
</html>

The combination of Tailwind’s utility-first approach and Stimulus’s lightweight controllers creates a dark mode implementation that’s both developer-friendly and user-friendly. Users get instant theme switching with proper persistence and no visual glitches, while developers get a clean, maintainable codebase that doesn’t require complex CSS variable management.

Have you implemented dark mode in your Rails applications? I’d love to hear about your approach and any challenges you’ve encountered in the comments!