laravel-ux/dark-mode
Add dark mode to Laravel apps with a simple toggle UI and persistent user preference. laravel-ux/dark-mode offers a lightweight, Blade-friendly setup for theme switching, helping you deliver comfortable light and dark experiences without custom JavaScript.
This is the first tagged release (v0.1.0), so treat the API as unstable — pin it ("vendor/ux-dark-mode": "0.1.*") until a 1.0 lands.
Install the package:
composer require vendor/ux-dark-mode
Publish any publishable assets (config/views), if the package exposes them:
php artisan vendor:publish --tag="ux-dark-mode-config"
php artisan vendor:publish --tag="ux-dark-mode-views"
Enable class-based dark mode in Tailwind, since the package toggles a dark class on <html>:
// tailwind.config.js
export default {
darkMode: 'class',
// ...
}
Drop the Blade directive into your layout <head>, before your CSS and before Alpine/Livewire boot:
<head>
@darkModeScripts
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
Add the toggle to your navigation or user menu:
<livewire:ux.dark-mode::toggle />
First use case to verify: load a page, click the toggle, confirm the dark class flips on <html>, the page re-renders in dark styles, and the choice survives a full page reload (session persistence).
Layout-level initialisation. The @darkModeScripts directive is the single source of truth for setting the root class on boot. Put it in exactly one place (your base layout) and never duplicate it in nested layouts — double-initialisation causes flicker and competing writes to the root class.
Toggle in a nav / user menu. The shipped ux.dark-mode::toggle component is built on Laravel UX UI and Icons, so it slots into a dropdown or top bar without custom markup. Override its view if you need different iconography or a compact variant rather than rebuilding the behaviour.
Listening for changes outside Livewire. The package emits a dark-mode-toggled browser event, which is the clean integration point for anything that needs to react — charts, embedded iframes, third-party widgets:
window.addEventListener('dark-mode-toggled', (event) => {
chart.setTheme(event.detail?.theme ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light'));
});
Reading state in Alpine. Because the package synchronises an Alpine darkMode store, you can bind without touching the DOM yourself:
<div x-data x-bind:class="$store.darkMode.isDark ? 'bg-gray-900' : 'bg-white'">
...
</div>
Use $store.darkMode as a read source. Mutating it directly bypasses the toggle's session write — go through the component or dispatch the event so persistence stays consistent.
Custom toggles. If you need a switch, segmented control, or a menu item outside the supplied component, keep your control thin: trigger the same state change the Livewire component triggers, and let the package own the root class and session key. This keeps a single writer and avoids the two-sources-of-truth bug that plagues hand-rolled dark mode.
Livewire Navigate / SPA transitions. The Alpine store plus session persistence means the toggle survives wire:navigate transitions. Confirm this on a real navigation pass — if the root class drops during a transition, the fix is almost always a script-ordering problem in the layout, not the component.
AI-assisted scaffolding. The release ships a Laravel Boost skill. If you use Boost, it will generate the layout directive placement, Tailwind config change, and component inclusion for you — useful for onboarding a second app quickly, but review the generated Tailwind darkMode setting, which is the most commonly missed manual step.
Tailwind darkMode: 'class' is mandatory. The package writes a class to <html>; with Tailwind's default media strategy your dark: utilities will ignore the toggle entirely and follow OS preference. This is the number-one "the toggle does nothing" report.
Directive placement prevents flash-of-wrong-theme. @darkModeScripts must run in <head> before first paint. Moving it to the end of <body> (a common "cleanup" refactor) reintroduces a white flash on dark-preferring sessions.
Script ordering versus Alpine. The directive initialises the Alpine darkMode store. It must load before Alpine starts; if you load Alpine with defer in <head> and the directive after it in <body>, the store registration can race and $store.darkMode will be undefined in your bindings.
v0.1.0 means no stability promise. The directive name, component alias (ux.dark-mode::toggle), event name (dark-mode-toggled), and Alpine store shape are all fair game for renaming in 0.x. Pin the version and add a smoke test around the toggle so a minor bump that changes the event name fails loudly rather than silently breaking your listeners.
Session persistence has a server-side component. The stored preference is session-backed, so requests outside the user's session (queue jobs, API-only routes, signed URLs opened in a fresh session) will render without the preference. Do not rely on the package for anything server-rendered in an email or PDF context.
Testing. Assert against the root class on a rendered response and against the session key after a toggle interaction, rather than asserting on specific markup from the UX UI component — that markup is the most likely thing to change between releases.
Extension points are the view and the event, not the internals. Publish and override the component view for presentation changes; listen to dark-mode-toggled for behavioural changes. Avoid subclassing or copying the component's PHP — you lose session-persistence fixes.
Debugging checklist, in order: is darkMode: 'class' set; is @darkModeScripts in <head>; does <html> gain the class on click; does it survive reload (session); does it survive wire:navigate. That sequence isolates Tailwind config → script loading → persistence → SPA lifecycle, which covers nearly every failure mode.
How can I help you explore Laravel packages today?