Theme Controller
Every WireKit app needs a dark-mode toggle, and every one of them hand-rolls the same three things: the class switch, the persistence, and the head script that stops the page flashing white before it turns dark.
This is those three things.
Only the last one answers both halves of the question at once — which mode is on, and what it looks like. Open it and the active row is marked; the trigger changes with it.
Setup
Two steps, and the first one is not optional.
{{-- 1. In your layout's <head>, BEFORE any stylesheet.
This applies the reader's stored theme before the first paint. --}}
<head>
@wirekitThemeScript
@wirekitStyles
</head>
{{-- 2. Put a control wherever it belongs. --}}
<x-wirekit::theme-controller />
That is the whole integration. No Alpine store, no localStorage plumbing, no
document.documentElement.classList in your own code.
The head script must be inline, and it must come first
@wirekitThemeScript is a synchronous inline script on purpose. Anything deferred
or external runs after the browser has painted — so the reader sees a white page
that then turns dark. That flash is the whole reason the directive exists, and
moving it below your stylesheet or into your bundle brings it straight back.
If you run a Content Security Policy, pass your nonce: @wirekitThemeScript($nonce).
Variants
variant |
Shape | Use it when |
|---|---|---|
button |
An icon button that flips light/dark | A header or toolbar (the default) |
switch |
A labeled switch | A settings row |
select |
System / Light / Dark | A settings row where the reader may say system |
menu |
A button naming the active mode, with a menu of all three | A header or footer where the reader should SEE which mode is on — and still be able to say system |
<x-wirekit::theme-controller variant="switch" label="Dark mode" />
Which one names the active mode
Only two of the four can, and only one of those carries an icon:
buttonshows a glyph and nothing else. A lone sun is ambiguous — it may mean the mode that is on, or the one a click would produce, and those are opposites. It also cannot saysystem, so the first click leaves that state silently.switchsays which of two. Same missing third state.selectsays all three in words. A native<option>takes no markup, so it can never show an icon — that is the browser rather than an omission here.menuis the one that does both: the trigger shows the active mode's icon and its name, and the menu lists all three with theirs.
{{-- 1. Reads "☀ Light" / "☾ Dark" / "🖥 System" — whichever is actually on. --}}
{{-- 2. `hideLabel` drops to the icon alone where the name would be a line too wide. --}}
<x-wirekit::theme-controller variant="menu" />
The switch and select variants print their label beside the control, which is
right in a settings row and a line too wide in a header. Pass hideLabel there: the
<label> wraps the control, so the name stays with the element for a screen reader
even once it is off the screen.
{{-- A header toggle that can still say "system", with no visible caption. --}}
<x-wirekit::theme-controller variant="select" label="Appearance" hideLabel />
System is a real answer
With nothing stored, the page follows the reader's operating system — a first visit should look like the rest of their machine, not like our default. And it keeps following: if their Mac turns dark at sunset, so does the page, live.
The moment they pick light or dark explicitly, that wins and it is remembered.
Two of the four variants can take them back to system: select lists it as an
option, and menu lists it as a row. Either one is a complete answer.
button and switch cannot, and that is the honest limitation of a two-state
control rather than an omission here: it has to freeze the reader onto an explicit
choice the instant they touch it, and after that their sunset leaves your page
behind. If that matters for your app, reach for select in a settings row or
menu in a header.
Reacting to a theme change
The control dispatches an event, so anything with its own colors — a chart, a map, a third-party embed — can follow along instead of polling for a class:
<div x-data x-on:wirekit:theme-changed.window="chart.setTheme($event.detail.dark ? 'dark' : 'light')">
…
</div>
| Detail | Type | Description |
|---|---|---|
theme |
string |
system, light or dark — what the reader chose. |
dark |
bool |
Whether the page is dark right now, whatever the reason. |
Where the choice is stored
By default in localStorage, under wirekit-theme. Change the key in
config/wirekit.php:
'theme' => [
'storage_key' => 'my-app-theme',
],
The control and the head script both read that key. They must agree — if they disagree, the page paints one theme and then switches to the other in front of the reader.
Choosing system removes the key rather than storing the word: no key already means "follow the OS", and one meaning in one place is worth more than a value that would be handled identically anyway.
Storage driver: local or cookie
localStorage is client-only — the server never sees the choice, so the first
paint has to be corrected by the head script. If your app renders dark mode on the
server (reading a request cookie to decide the <html> class before it sends
the HTML), switch the driver to cookie:
'theme' => [
'storage' => 'cookie', // 'local' (default) | 'cookie'
'storage_key' => 'wirekit-theme',
'cookie_attributes' => [
'same_site' => 'Lax', // Lax is right for a UI preference
'max_age' => 31536000, // one year, in seconds
'path' => '/',
],
],
With the cookie driver the control writes document.cookie instead of
localStorage, and the head script reads it back. The Secure attribute is added
automatically on HTTPS (and left off on plain-HTTP local dev, where a Secure
cookie would be dropped).
The payoff: your server can read the same cookie and render the correct theme
before the first byte leaves the server, so there is no flash to correct at all
— the head script becomes a safety net for the system case:
{{-- In your layout's <html> tag. request()->cookie() reads the value the control
wrote on the previous request. --}}
<html @class(['dark' => request()->cookie('wirekit-theme') === 'dark'])>
EncryptCookies: add the key to $except
The control writes the cookie from JavaScript, so it is not encrypted. If your
app runs Laravel's EncryptCookies middleware (the default), it will treat the
plain cookie as tampered and drop it on the server read — request()->cookie()
returns null and server-side rendering silently never happens. Add the storage
key to the middleware's $except list:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: ['wirekit-theme']);
})
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
string |
'button' |
button, switch, select, menu. |
size |
string |
'md' |
Button variant only: sm (32px) or md (36px). Matches the button size scale. |
surface |
string |
'filled' |
Button variant only: filled (bordered, elevated) or ghost (borderless, transparent, muted) so the toggle sits flush next to surface="ghost" buttons in a top bar. |
options |
array|null |
null |
select and menu: re-word the three modes, e.g. ['system' => 'Automatic']. The select uses them as its <option> text; the menu uses the same wording twice, on the trigger and on the matching row. Keys you omit keep their translated default. |
label |
string |
'Dark mode' |
Accessible name for button, switch and select. The button has no visible text, so this is all a screen reader gets there. The menu variant does not use it — its trigger names the mode that is on, taking that wording from options, so a fixed caption would contradict what the button says. |
hideLabel |
bool |
false |
Keep the name as the control's accessible name but take it off the screen — for a switch, select or menu in a header or toolbar. On the switch and select it hides the caption beside the control; on the menu it hides the mode name on the trigger, leaving the icon alone, while the three rows inside the menu keep their names. No effect on button, which has no visible text to begin with. Same prop as input / select / textarea / combobox / checkbox. |
scope |
string|null |
null |
Class-scope override. Also reaches the inner <button> via the control class block. |
Button chrome (size, surface, icons)
The button variant matches the button size and surface vocabulary, so a top-bar toggle can sit flush next to your other controls:
{{-- A 32px borderless toggle for a ghost top bar. --}}
<x-wirekit::theme-controller size="sm" surface="ghost" />
Supply your own glyphs — and choose the polarity (show the current state, or
the action the reader would take) — through the icon-on / icon-off slots.
icon-on shows while the page is dark, icon-off while it is light:
<x-wirekit::theme-controller>
<x-slot:icon-on><x-wirekit::icon name="moon" /></x-slot:icon-on>
<x-slot:icon-off><x-wirekit::icon name="sun" /></x-slot:icon-off>
</x-wirekit::theme-controller>
Accessibility
- The button variant carries
aria-pressed: "the page is dark" is a state, and a reader who cannot see the icon still needs to know which way the switch is thrown. The icon alone would be shape-and-color only. - The switch variant is a real
<input type="checkbox" role="switch">— focusable, toggles on Space, announces its own state. A styled<div>would have to reimplement all three. - The select is a native
<select>, so it gets the platform picker on a phone. - The menu variant composes dropdown rather than rebuilding
a menu, so it inherits that pattern whole: the trigger carries
aria-haspopup,aria-expandedandaria-controls, each row is amenuitem, and dismissing the menu from inside it returns focus to the trigger. - The mode that is on is marked twice in that menu, because one mark reaches only
half the readers.
aria-current="true"says it; weight and accent color show it. It is deliberately not a filled row — hover and focus already use the subtle surface, so a third background would be indistinguishable from them at the moment it matters, and the active mark has to stay legible underneath both. - The sun/moon swap hides the inactive icon from assistive tech, so a toggle announces one state rather than both.
- If
localStorageis unavailable — private mode, storage disabled — the control still works for the current page; the choice simply does not survive a reload. The head script fails the same way, into the OS preference, rather than taking the page down before it renders.
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Move focus to the control. |
| Space | Toggle the button and the switch variants. |
| ↑ ↓ | Change the selection in the select variant (native behavior). |
| Enter Space (on the menu trigger) | Open the menu. Focus lands on the first mode. |
| ↑ ↓ (in the open menu) | Walk the three modes, wrapping at either end. |
| Enter Space (on a mode) | Apply it and close the menu. |
| Esc | Close the menu without changing the mode, and return focus to the trigger. |
The menu variant is the keyboard-heavy one, and none of that model is written here: it composes dropdown and inherits the whole thing, so Home / End, type-ahead and the Tab-closes-and-moves-on behavior are documented on that page rather than repeated on this one.
Opening it focuses the first mode, which is not the same as selecting it — and the two
never look alike. The ring is focus-visible, the browser's own read on whether the
reader is navigating by keyboard, so a menu opened with the mouse draws no ring at all.
When the ring and the active mark do land on the same row, they stay legible together:
one is a ring, the other is weight and color.