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
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:
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:
<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
- Open
tailwind.config.jsand setdarkMode: 'class'explicitly (it defaults to'media'if omitted). - Move the toggle logic so it adds/removes the
darkclass ondocument.documentElement(i.e.<html>), notdocument.body. - Add persistence with
localStorageso the theme choice survives page reloads and navigation. - Delete Vite's cache and restart the dev server:
rm -rf node_modules/.vite && npm run dev. - Hard-refresh the browser and toggle dark mode, confirming
dark:utilities now visibly apply. - Check any components using Alpine's
x-cloakfor 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.