Skip to main content
WireKit
Copy for LLM

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.

The four control variants

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.

The menu variant, with and without its name

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:

  • button shows 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 say system, so the first click leaves that state silently.
  • switch says which of two. Same missing third state.
  • select says 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.
  • menu is 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.

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-expanded and aria-controls, each row is a menuitem, 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 localStorage is 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.

Was this page helpful?

Voting requires cookies or local storage. What we store