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 three control variants

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 You want the reader to be able to say system
<x-wirekit::theme-controller variant="switch" label="Dark mode" />

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.

Only variant="select" can take them back to system. That is the honest limitation of a two-state control: 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, use the select.

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

In localStorage, under wirekit-theme. Change it 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.

Props

Prop Type Default Description
variant string 'button' button, switch, select.
label string 'Dark mode' Accessible name. The button variant has no visible text, so this is all a screen reader gets.
scope string|null null Class-scope override.

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 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).