Tailwind CSS v4 Dark Mode in Laravel — CSS-First with @custom-variant

Wire class-based Tailwind v4 dark mode into a Laravel app with @custom-variant, @theme tokens, an Alpine toggle, localStorage, and an anti-FOUC script.

Steven Richardson
Steven Richardson
· 11 min read

You upgrade a Laravel project to Tailwind v4. Half the dark-mode utilities go missing and tailwind.config.js is gone — there is no darkMode: 'class' key to flip. Every Stack Overflow answer is two versions out of date. The official docs assume you already grasp @custom-variant and skip the Laravel-specific glue.

This walkthrough builds Tailwind v4 dark mode end-to-end in a Laravel app. One line in app.css, light and dark tokens inside @theme, an Alpine toggle that respects user preference, a one-key localStorage entry, and the inline <head> script that prevents the white flash on first paint. Twenty minutes, no extra packages, no tailwind.config.js.

Add @custom-variant dark to your app.css#

Tailwind v4 ships with dark: already wired to @media (prefers-color-scheme: dark). That works out of the box — if a visitor's OS is in dark mode, your dark:bg-zinc-900 utilities activate immediately. No configuration required. For a lot of projects that is the right answer; you serve the dark experience automatically and never write another line.

The moment you want a manual toggle — a sun/moon button in the navigation, a preference saved per-user — you need to override the variant. In v4 you do that entirely from your stylesheet. If you have just finished a Laravel 12 to 13 upgrade, you have probably already swapped the Tailwind PostCSS plugin for @tailwindcss/vite and removed tailwind.config.js. Open resources/css/app.css and add the @custom-variant line right after the import:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

That single declaration replaces darkMode: 'class'. The selector &:where(.dark, .dark *) matches the element itself when it has the .dark class, plus any descendant of a .dark element. The :where() wrapper keeps specificity at zero so utilities still beat your component CSS — important if you're layering existing styles on top.

A few projects swap to a data attribute instead — better for SSR frameworks and easier to assert in browser tests. Use @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); and toggle data-theme="dark" on <html> instead of the class. Pick one strategy and stay with it; mixing class and data-attribute selectors invites specificity drift.

Define light and dark tokens inside @theme#

The next problem is paint. bg-white dark:bg-zinc-900 works fine when you only have two surfaces, but real apps have a dozen — page background, card surface, sidebar, divider, muted text, link colour. Sprinkling dark: everywhere doubles your class lists and forces every component to know it has two states. Define tokens once and let the variant flip them.

In v4 there are two ways to do this. The first puts the tokens directly into @theme and overrides them with raw CSS for the .dark selector:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
    --color-bg: oklch(99% 0 0);
    --color-surface: oklch(96% 0.005 240);
    --color-fg: oklch(15% 0.02 240);
    --color-muted: oklch(45% 0.02 240);
    --color-border: oklch(90% 0.005 240);
}

.dark {
    --color-bg: oklch(15% 0.02 240);
    --color-surface: oklch(20% 0.025 240);
    --color-fg: oklch(96% 0.005 240);
    --color-muted: oklch(70% 0.02 240);
    --color-border: oklch(28% 0.025 240);
}

Tailwind picks up every --color-* variable inside @theme and generates utilities for it — bg-bg, bg-surface, text-fg, border-border. The .dark block doesn't add new utilities; it only swaps the values of the variables Tailwind already knows about. Apply tokens once on the component and you never write dark: again:

<article class="bg-surface text-fg border-border rounded-2xl border p-6">
    <h2 class="text-fg text-xl font-semibold">Order #4821</h2>
    <p class="text-muted mt-2">Refunded 14 May 2026</p>
</article>

The second approach uses @theme inline to point Tailwind at variables you maintain elsewhere — useful when a design system already publishes :root and .dark variable blocks and you only want Tailwind to generate the utilities. Use oklch for the values; the perceptual lightness scale makes light/dark pairs predictable to read and edit. Stick with one form. Mixing inline and non-inline @theme blocks for the same token will burn an hour later.

Toggle the .dark class on with Alpine.js#

You have the variant and the tokens. Now the user needs a button. Alpine is the right reach here — the toggle is pure client-side state and there is no reason to make a round-trip to a Livewire component for it. If you're already running Alpine inside Livewire 4 components, this layers in cleanly with no extra setup.

Add a button to resources/views/layouts/app.blade.php, inside the navigation or wherever your site chrome lives:

<button
    type="button"
    x-data="{
        theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
        toggle() {
            this.theme = this.theme === 'dark' ? 'light' : 'dark';
            document.documentElement.classList.toggle('dark', this.theme === 'dark');
            localStorage.setItem('theme', this.theme);
        }
    }"
    @click="toggle()"
    aria-label="Toggle dark mode"
    class="text-fg hover:bg-surface inline-flex h-9 w-9 items-center justify-center rounded-lg transition"
>
    <span x-show="theme === 'light'" aria-hidden="true">&#9788;</span>
    <span x-show="theme === 'dark'" aria-hidden="true">&#9789;</span>
</button>

A few details matter. The initial theme value reads from document.documentElement.classList rather than from localStorage directly — the inline <head> script in the next step has already done that work, so the toggle just mirrors what is on screen. toggle() flips the class on <html> because that is the element your @custom-variant selector matches. aria-label keeps the button accessible for the screen-reader users who would otherwise see an unlabelled glyph.

If you'd rather build the toggle as a reusable Blade component, drop the same Alpine into resources/views/components/theme-toggle.blade.php and call it with <x-theme-toggle /> wherever you need it. The same skeleton works inside Livewire and Filament components — Filament's panel ships its own toggle, but if you're building a custom Filament v4 form field or a non-Filament Livewire dashboard, this is exactly the pattern.

Persist the preference in localStorage#

The toggle writes localStorage.theme on every click. That covers persistence within the same browser — close the tab, come back tomorrow, the preference survives. But three edge cases need explicit handling.

First, "follow the system" is a real preference and should be the default. Treat the absence of a theme key as "use whatever the OS says" rather than forcing a value. Second, users with multiple devices expect each device to remember its own choice — localStorage is correctly scoped per-origin per-device, so do not try to sync it across tabs of the same site with BroadcastChannel unless you have a specific reason. Third, an explicit "Reset to system" option is worth adding for users who toggled by accident.

Extend the Alpine toggle with a three-way cycle:

<button
    type="button"
    x-data="{
        theme: localStorage.getItem('theme') ?? 'system',
        cycle() {
            const order = ['system', 'light', 'dark'];
            this.theme = order[(order.indexOf(this.theme) + 1) % order.length];
            if (this.theme === 'system') {
                localStorage.removeItem('theme');
                const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
                document.documentElement.classList.toggle('dark', prefersDark);
            } else {
                localStorage.setItem('theme', this.theme);
                document.documentElement.classList.toggle('dark', this.theme === 'dark');
            }
        }
    }"
    @click="cycle()"
>
    <span x-text="theme"></span>
</button>

The system branch deletes the key entirely and re-asks the OS — the cleanest way to "unset" a stored preference. Two-state toggles work for most marketing sites; the three-state cycle is the right call for any app where users live in the product for hours.

Avoid the FOUC with an inline script in app.blade.php#

Even with the toggle wired up, you will see a flash of white on every page load. Vite ships the CSS as a separate stylesheet and the browser paints the default light background before Alpine runs and toggles the class. The fix is small and runs synchronously in <head> — before any paint, before Vite, before Alpine.

Add this to resources/views/layouts/app.blade.php, inside <head> and before @vite:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <script>
        (() => {
            const stored = localStorage.getItem('theme');
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            const shouldBeDark = stored === 'dark' || (stored === null && prefersDark);
            document.documentElement.classList.toggle('dark', shouldBeDark);
        })();
    </script>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Three rules for this block. It must be inline — an external script defeats the point because the browser paints before fetching. It must run before the first stylesheet — put it above @vite. And it must be tiny — anything beyond reading localStorage and toggling a class belongs in your bundle, not in the critical path.

Wrap it in an IIFE so the temporary variables stay out of the global scope. Some Content Security Policy setups block inline scripts entirely; if yours does, add the script's SHA-256 hash to your CSP script-src directive rather than dropping the 'unsafe-inline' keyword.

Test the toggle against system preference#

The last thing to verify is that all three states behave correctly when the OS preference changes mid-session. Open the page with localStorage.theme removed, then flip the OS between light and dark — the page should follow. Open it with localStorage.theme = 'dark', then flip the OS to light — the page should stay dark. The inline script handles the first paint; you also want the live update for visitors who change their OS theme without reloading.

Add a matchMedia listener that only activates when there is no stored preference:

<script>
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
        if (localStorage.getItem('theme') === null) {
            document.documentElement.classList.toggle('dark', event.matches);
        }
    });
</script>

For automated coverage, browser tests are the only way to assert this end-to-end — unit tests can't observe matchMedia accurately. If you're already running Pest 4 browser tests with Playwright, add three cases: stored = "dark" stays dark when OS flips; stored = "light" stays light; stored = null follows the OS. Three assertions, ten lines each, and you catch every regression the toggle could introduce.

Gotchas and Edge Cases#

The @import "tailwindcss" line must come before @custom-variant dark. Tailwind v4 processes the file top to bottom and @custom-variant only resolves once the framework has registered. Putting @custom-variant first silently drops the override and dark: continues to follow prefers-color-scheme.

@theme inline and @theme are not interchangeable. With @theme, Tailwind embeds the value into a global CSS variable, which means .dark { --color-bg: ... } overrides it cleanly. With @theme inline, Tailwind inlines the literal value into every utility class — and your .dark override has nothing to flip. The discussion at tailwindlabs/tailwindcss#18560 walks the trade-off; for dark mode tokens, you almost always want @theme (not inline).

document.documentElement.classList.toggle('dark', condition) is safer than add()/remove() because it accepts a forced boolean. toggle('dark') without the second argument flips the current state, which is the wrong semantic when you mean "set to dark, regardless of where we started."

If you support Livewire components that render server-side on every request, the inline script alone is enough — the class lands on <html> before Livewire mounts. But if you have any lazy-loaded Livewire 4 islands that render dynamic theme-aware content, make sure they read CSS variables rather than baking colour hexes into the HTML. The island re-renders inherit the current theme automatically when the colour is var(--color-bg); they don't if you hand-coded #0f172a.

x-cloak is still useful — add [x-cloak] { display: none !important; } to your CSS and x-cloak to any element that flashes Alpine's x-show defaults. The inline <head> script fixes the theme flash; x-cloak fixes the Alpine-state flash. Different problems, both worth solving.

Browsers cache localStorage per-origin. If you serve the same Laravel app on app.example.com and staging.example.com, each has its own theme key. Production debuggers occasionally trip on this when they "set dark mode" on staging and wonder why production stays light — different origins, different storage.

Wrapping Up#

The full Tailwind v4 dark mode stack is one CSS line, one @theme block, one Alpine toggle, and one inline <head> script. No tailwind.config.js. No PostCSS plugin. No package install. The pattern scales from a marketing site to a Filament-driven admin panel without changing shape.

From here, the next jumps are usually either pushing theme awareness deeper into your component library or wiring per-user preferences into your auth state. If the latter, persist the choice on the user model and emit a tiny <script> block that prefers the server value over localStorage — useful for SaaS apps where the same user signs in on different devices. And if you're packaging this as a reusable component, building custom Filament v4 form fields shows the same Alpine-plus-Tailwind pattern in a more structured form.

FAQ#

How do I enable class-based dark mode in Tailwind CSS v4?

Add @custom-variant dark (&:where(.dark, .dark *)); to resources/css/app.css, right after @import "tailwindcss". That single line replaces the old darkMode: 'class' config key. Toggle the .dark class on <html> with JavaScript or Alpine and every dark: utility activates. No tailwind.config.js, no extra build steps, no PostCSS plugin needed.

Where does Tailwind v4 dark mode configuration live without tailwind.config.js?

It lives directly in your stylesheet, alongside @import "tailwindcss" and @theme. The @custom-variant directive replaces every option that used to sit under darkMode in v3. Class strategy, attribute strategy, and the default prefers-color-scheme strategy are all expressed as CSS selectors inside that one directive. There is no JavaScript config file, no PostCSS plugin entry, and no Vite-specific setup — everything dark-mode-related is CSS.

How do I make Tailwind v4 dark mode follow a user toggle instead of OS preference?

Override the dark variant with a selector that you control, then toggle that selector with JavaScript. The CSS line is @custom-variant dark (&:where(.dark, .dark *));. The toggle is document.documentElement.classList.toggle('dark', shouldBeDark). Persist the user's choice to localStorage so it survives page reloads, and add an inline <head> script that reads the stored value before paint to avoid a flash. Alpine wraps the whole thing in three lines of x-data.

How do I store the dark mode preference in localStorage with Alpine.js?

Read it in x-data with localStorage.getItem('theme') ?? 'system' and write it in the click handler with localStorage.setItem('theme', this.theme). For a "reset to system preference" option, call localStorage.removeItem('theme') and re-evaluate window.matchMedia('(prefers-color-scheme: dark)').matches. Using a single string key — 'theme' with values 'light', 'dark', or unset — is simpler than a boolean and gives you a clean three-state cycle.

Does Tailwind v4 require any JavaScript for dark mode to work?

No — if prefers-color-scheme is enough for your use case, Tailwind v4 ships dark mode active by default with zero JavaScript. The dark: utility variant follows the OS preference automatically. JavaScript is only needed when you want a manual toggle that overrides the OS — and even then, the script is a single classList.toggle call. The CSS pipeline does everything else.

How do I define dark mode theme tokens using @theme?

Put your light values inside @theme { --color-bg: ...; --color-fg: ...; } and override them inside a plain .dark { --color-bg: ...; --color-fg: ...; } block in the same stylesheet. Tailwind generates utilities like bg-bg and text-fg from the @theme declarations, and the .dark block swaps the underlying variable values when the class is present. Use @theme (not @theme inline) for tokens that need to flip with the variant — inline bakes the value into each utility and there is nothing left to override.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.