Fixed: Tailwind Dark Mode Not Toggling in Laravel Blade

The problem: Clicking a dark mode toggle button adds the dark class to <html> correctly, but none of the dark: utility classes actually change how the page looks.

Environment: Laravel 11, Tailwind CSS 3.x, Alpine.js 3.x, Vite 5

The Error

● ● ●  behavior (no console error)
No JS error thrown.
DevTools Elements panel confirms:
  <html class="dark">
...but dark:bg-gray-900 and similar utilities
have zero visual effect on the page.

Why It Happens

Tailwind supports two dark mode strategies: media (follows the OS-level preference automatically) and class (toggled manually via a class on a parent element). By default, Tailwind ships with media strategy — so if your toggle button is adding a dark class expecting class-strategy behavior, Tailwind's compiled CSS never generated the dark: variants that respond to a class at all. It's still watching the OS setting instead.

Even after switching the config to class strategy, this can still fail for two more reasons:

1. The class needs to sit on <html>, not <body>. Tailwind's generated selector is .dark <utility>, which works no matter where the class sits structurally — but many toggle scripts accidentally target document.body out of habit from older frameworks, and if any dark-mode-aware element sits outside body (rare, but happens with modals/portals), it won't inherit the style.

2. Vite's build didn't pick up the config change. Changing darkMode in tailwind.config.js requires a full rebuild, not just a hot-reload, because it changes what selectors Tailwind's compiler generates in the first place — an HMR update alone won't regenerate the entire dark-mode variant set.

The Fix

First, explicitly set class strategy in your Tailwind config:

tailwind.config.js
module.exports = {
    darkMode: 'class',
    content: [
        './resources/**/*.blade.php',
        './resources/**/*.js',
        './resources/**/*.vue',
    ],
    // ...
}

Then make sure your toggle logic targets <html>, and persists the preference so it survives reloads:

resources/views/layouts/app.blade.php
<html x-data
      x-init="darkMode = localStorage.getItem('darkMode') === 'true';
              $watch('darkMode', val => localStorage.setItem('darkMode', val))"
      :class="{ 'dark': darkMode }">
<body>
    <button @click="darkMode = !darkMode">Toggle</button>
    ...
</body>
</html>

Finally, force a full rebuild — a plain HMR update won't regenerate the new dark-mode variant selectors:

rm -rf node_modules/.vite
npm run dev

Step-by-Step

  1. Open tailwind.config.js and set darkMode: 'class' explicitly (it defaults to 'media' if omitted).
  2. Move the toggle logic so it adds/removes the dark class on document.documentElement (i.e. <html>), not document.body.
  3. Add persistence with localStorage so the theme choice survives page reloads and navigation.
  4. Delete Vite's cache and restart the dev server: rm -rf node_modules/.vite && npm run dev.
  5. Hard-refresh the browser and toggle dark mode, confirming dark: utilities now visibly apply.
  6. Check any components using Alpine's x-cloak for the toggle button itself, so it doesn't flash in the wrong initial state before Alpine initializes.

Edge case: If you rely purely on localStorage without also checking window.matchMedia('(prefers-color-scheme: dark)') as a fallback for first-time visitors, new users always land in light mode regardless of their OS setting — add a fallback check in your x-init so first-time visitors get a sensible default instead of always light mode.

Still not seeing dark mode apply after all this? Reach out on the Contact page and I'll help debug your config.

Advertisement
Advertisement