---
title: Theme Controller
description: A drop-in dark-mode control — no JS wiring, no flash of the wrong theme.
category: Layout
related:
  - /components/swap
  - /theming
  - /components/toggle-button
  - /components/dropdown
visibility: guest
draft: false
---

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

:::preview{title="The four control variants"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::theme-controller />
    <x-wirekit::theme-controller variant="switch" />
    <x-wirekit::theme-controller variant="select" label="Appearance" />
    <x-wirekit::theme-controller variant="menu" />
</x-wirekit::row>
:::

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.

:::preview{title="The menu variant, with and without its name"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::theme-controller variant="menu" />
    <x-wirekit::theme-controller variant="menu" hide-label />
</x-wirekit::row>
:::

## Setup

Two steps, and the first one is not optional.

```blade
{{-- 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.

::: warning 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** |

```blade
<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.

```blade
{{-- 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.

```blade
{{-- 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:

```blade
<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`:

```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`:

```php
'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:

```blade
{{-- 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'])>
```

::: warning 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:

```php
// 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](/components/button) size and surface
vocabulary, so a top-bar toggle can sit flush next to your other controls:

```blade
{{-- 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:

```blade
<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 <kbd>Space</kbd>, 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](/components/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](/components/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 |
|-----|--------|
| <kbd>Tab</kbd> | Move focus to the control. |
| <kbd>Space</kbd> | Toggle the button and the switch variants. |
| <kbd>↑</kbd> <kbd>↓</kbd> | Change the selection in the select variant (native behavior). |
| <kbd>Enter</kbd> <kbd>Space</kbd> (on the menu trigger) | Open the menu. Focus lands on the first mode. |
| <kbd>↑</kbd> <kbd>↓</kbd> (in the open menu) | Walk the three modes, wrapping at either end. |
| <kbd>Enter</kbd> <kbd>Space</kbd> (on a mode) | Apply it and close the menu. |
| <kbd>Esc</kbd> | 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](/components/dropdown) and inherits the whole thing, so
<kbd>Home</kbd> / <kbd>End</kbd>, type-ahead and the <kbd>Tab</kbd>-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.
