Introduction
Dark mode is a baseline user expectation now. With Tailwind v4 and a small amount of Alpine.js, you can ship a theme toggle that respects the system preference, lets users override it, and persists the choice across page loads — without a single line of custom CSS.
Key Concepts
dark:variant: A Tailwind utility prefix (dark:bg-gray-900) that applies only when dark mode is active.- Dark mode strategy: Tailwind can trigger the variant from the OS preference (
media) or from a class/attribute on the<html>element (class/selector). The class strategy lets you override the OS preference programmatically. - Alpine.js: A lightweight JavaScript framework included in Laravel's starter kits that fits naturally into Blade for small interactions.
Real World Context
Hard-coding a theme means half your users squint at a bright page at night or a dim one in sunlight. A persistent toggle — OS-respecting by default but user-overridable — is standard practice across modern web apps.
Deep Dive
Enabling the Class Strategy
Tailwind v4 reads the dark-mode strategy from CSS. Add this to resources/css/app.css:
css@import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *));
Now dark: utilities apply when an ancestor (typically <html>) has the dark class.
Writing Dark-Mode-Aware Markup
Style both modes in the same element:
blade<body class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100"> <header class="border-b border-gray-200 dark:border-gray-700"> <h1>My App</h1> </header> </body>
Every dark: utility is a counterpart to a base utility — the base applies in light mode, the dark: variant applies when <html class="dark">.
The Theme Toggle Component
Build an Alpine.js toggle that syncs with localStorage and the system preference. Put it in resources/views/components/theme-toggle.blade.php:
blade<button x-data="{ theme: localStorage.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'), init() { this.apply(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.theme) this.theme = e.matches ? 'dark' : 'light'; this.apply(); }); }, toggle() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.theme = this.theme; this.apply(); }, apply() { document.documentElement.classList.toggle('dark', this.theme === 'dark'); } }" @click="toggle()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800" :aria-label="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'" > <span x-show="theme === 'light'">🌙</span> <span x-show="theme === 'dark'">☀️</span> </button>
Drop it into your layout with <x-theme-toggle />.
The init() method runs once, sets the current theme from storage or the OS preference, and subscribes to prefers-color-scheme changes so users who haven't explicitly chosen a theme follow their OS. The toggle() method flips the theme and writes to localStorage so the choice survives page reloads.
Preventing Flash of Wrong Theme
If you only apply the theme via Alpine on mount, users briefly see the wrong theme while the page loads. Fix it with an inline script in <head> that runs before paint:
blade<head> <script> (function () { const theme = localStorage.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); if (theme === 'dark') document.documentElement.classList.add('dark'); })(); </script> </head>
This runs synchronously before Tailwind reads the body, so there's no flash.
Common Pitfalls
- Relying on
prefers-color-schemealone — Users can't override the OS preference, which some will want to. Use the class strategy plus a toggle. - Forgetting the FOUC (flash of unstyled content) fix — Applying the theme in a mounted Alpine component means the first paint uses the wrong colors.
- Mixing
dark:utilities with hard-coded colors — If you havecolor: #111in a custom stylesheet, it won't flip for dark mode. Use CSS variables or Tailwind utilities throughout.
Best Practices
- Start with system preference, let users override — Respect
prefers-color-schemeby default and persist a user override inlocalStorage. - Use
@themefor semantic colors — Define--color-surface/--color-textin Tailwind v4's@themeblock and reference them by name. Both modes stay consistent. - Put the no-FOUC script in the page
<head>— Inline, tiny, and synchronous. Deferring it causes flickering.
Summary
- The
dark:variant applies utilities only in dark mode; configure it to read from adarkclass on<html>. - An Alpine-powered toggle component can sync the class with
localStorageand the OS preference. - Use an inline script in
<head>to prevent a flash of the wrong theme on first paint. - Keep all color choices inside Tailwind utilities or
@themevariables so both modes stay in sync.