Theme Switcher
A light / dark / system theme toggle: a segmented radiogroup that sets `.dark` on the document, persists the choice to localStorage, and live-tracks the OS preference in system mode.
Preview
<div class="flex flex-col items-start gap-5">
<x-ui.theme-switcher />
<x-ui.theme-switcher :labels="false" aria-label="{{ __('Theme') }}" />
</div>
Installation
php artisan ui:add theme-switcher
Note
This component ships an Alpine behavior module at
resources/js/ui/theme-switcher.js. Import it once from your bundle so it registers on alpine:init:
import './theme-switcher.js';
Registry contract
php artisan ui:add theme-switcher
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/theme-switcher.blade.php -
resources/js/ui/theme-switcher.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Theme Switcher (`theme-switcher`)
A light / dark / system theme toggle: a segmented radiogroup that sets `.dark` on the document, persists the choice to localStorage, and live-tracks the OS preference in system mode.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add theme-switcher
```
## Usage
```blade
<div class="flex flex-col items-start gap-5">
<x-ui.theme-switcher />
<x-ui.theme-switcher :labels="false" aria-label="{{ __('Theme') }}" />
</div>
```
## Props
- `size` (md|sm|btn|icon, default `md`) — sm or md control density.
- `labels` (bool, default `true`) — Shows the text label next to each icon when true; false renders icon-only buttons (still labelled for assistive tech).
## Use when
- Use to document, inspect, and apply design-system decisions consistently across teams and AI tooling.
- Letting a visitor switch the current page between light, dark and system appearance.
- Adding a persistent theme toggle to a docs or app header.
## Avoid when
- Do not present token-level controls to end users who only need product functionality.
- Offering full runtime re-theming (colour presets, corner radius); use theme-customizer instead, which layers on top of the same dark-mode mechanism.
## Anti-patterns
- Exposing implementation tokens as product settings
## Rules
- Use the `<brok:theme-switcher>` tag (or `<x-ui.theme-switcher>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/theme-switcher
- Registry JSON (files, props, contract): https://brokui.dev/r/open/theme-switcher.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | md | sm | btn | icon | md | sm or md control density. |
| labels | bool | true | Shows the text label next to each icon when true; false renders icon-only buttons (still labelled for assistive tech). |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders a three-option radiogroup; selecting an option sets .dark on <html>, persists the choice to localStorage, and in system mode live-tracks the OS prefers-color-scheme.
- Sets style.colorScheme on <html> together with .dark, so native controls follow the chosen theme.
- Installs theme-script as a dependency: put <x-ui.theme-script /> in <head> before the stylesheet so the stored theme applies before the first paint instead of after Alpine starts.
- Programmatic control: window.UI.theme.set('light'|'dark'|'system') chooses, persists and applies a theme (get() returns the choice, isDark() the effective mode). A window set-theme event with detail { theme } does the same, so a Livewire $this->dispatch('set-theme', theme: 'dark') or a command palette item can switch the theme without a switcher on screen.
- Every change fires a window theme-changed event { theme, dark }; every switcher on the page follows it, so two switchers (a header and a settings page) never disagree. One page-wide matchMedia listener tracks the OS preference while the theme is system.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive.
Guidance
Inspect and govern design-system decisions.
Use when
- Use to document, inspect, and apply design-system decisions consistently across teams and AI tooling.
- Letting a visitor switch the current page between light, dark and system appearance.
- Adding a persistent theme toggle to a docs or app header.
Avoid when
- Do not present token-level controls to end users who only need product functionality.
- Offering full runtime re-theming (colour presets, corner radius); use theme-customizer instead, which layers on top of the same dark-mode mechanism.
Use instead
- Product-level controls for end users
Anti-patterns
- Exposing implementation tokens as product settings
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- Implements role="radiogroup"/role="radio" with roving tabindex and arrow-key navigation.
- Each option carries its own aria-label so it stays accessible even when labels is false.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="theme-switcher-{{ $record->id }}">
<div class="flex flex-col items-start gap-5">
<x-ui.theme-switcher />
<x-ui.theme-switcher :labels="false" aria-label="{{ __('Theme') }}" />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:theme-switcher
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// 'sm' | 'md' — control density.
'size' => 'md',
// Show the text label next to each icon. false → icon-only (each button
// still carries an aria-label so it stays accessible).
'labels' => true,
])
@php
$size = in_array($size, ['sm', 'md'], true) ? $size : 'md';
// Full class strings per size so Tailwind's JIT sees every utility.
$sizes = [
'sm' => ['btn' => 'h-7 gap-2 px-2 text-xs', 'icon' => 'size-4'],
'md' => ['btn' => 'h-8 gap-2 px-2 text-sm', 'icon' => 'size-4'],
];
$s = $sizes[$size];
// The three options, in radiogroup order. Each carries its accessible label.
$options = [
['value' => 'light', 'label' => __('Light')],
['value' => 'dark', 'label' => __('Dark')],
['value' => 'system', 'label' => __('System')],
];
// Shared per-segment classes. Active state is driven from Alpine via the
// bound `data-active` attribute so the look swaps without raw colour.
$btnBase = 'relative inline-flex shrink-0 items-center justify-center rounded-md font-medium '
.'text-muted-foreground transition-colors outline-none '
.'hover:text-foreground '
.'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background '
.'data-[active=true]:bg-background data-[active=true]:text-foreground data-[active=true]:shadow-sm '
.'motion-reduce:transition-none';
@endphp
{{-- Theme switcher: a segmented light/dark/system control. The behaviour layer
(uiThemeSwitcher) toggles `.dark` on <html>, persists the choice to
localStorage, and live-tracks the OS preference while in system mode.
role="radiogroup" with roving tabindex + arrow-key navigation. --}}
<div
data-slot="theme-switcher"
data-size="{{ $size }}"
x-data="uiThemeSwitcher()"
role="radiogroup"
aria-label="{{ __('Theme') }}"
@keydown="onKey($event)"
{{ $attributes->merge(['class' => 'inline-flex items-center gap-1 rounded-md bg-muted p-1']) }}
>
@foreach ($options as $i => $option)
<button
type="button"
role="radio"
data-value="{{ $option['value'] }}"
aria-label="{{ $option['label'] }}"
@click="set('{{ $option['value'] }}')"
:aria-checked="value === '{{ $option['value'] }}' ? 'true' : 'false'"
:data-active="value === '{{ $option['value'] }}'"
:tabindex="value === '{{ $option['value'] }}' ? 0 : -1"
class="{{ $btnBase }} {{ $s['btn'] }}"
>
@if ($option['value'] === 'light')
{{-- sun --}}
<svg class="{{ $s['icon'] }}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/><path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/><path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>
</svg>
@elseif ($option['value'] === 'dark')
{{-- moon --}}
<svg class="{{ $s['icon'] }}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
@else
{{-- monitor --}}
<svg class="{{ $s['icon'] }}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"/>
<path d="M8 21h8"/><path d="M12 17v4"/>
</svg>
@endif
@if ($labels)
<span class="select-none">{{ $option['label'] }}</span>
@endif
</button>
@endforeach
</div>
/**
* Theme switcher behaviour (light / dark / system).
*
* Drives a role="radiogroup" segmented control with three values:
* - 'light' → force light (remove `.dark`)
* - 'dark' → force dark (add `.dark`)
* - 'system' → follow the OS via matchMedia('(prefers-color-scheme: dark)')
*
* The chosen value is persisted to localStorage under the key "theme" and
* re-applied on init, so the choice survives reloads. `.dark` and
* `style.colorScheme` on <html> are set together.
*
* theme-script's theme-boot.js applies the same rule (same key, same three
* values, same media query) before the first paint; keep the two in step
* (ThemeScriptTest fails when they drift). While on 'system' a
* matchMedia `change` listener live-tracks the OS preference; switching away
* from 'system' removes that listener so nothing leaks.
*
* a11y: roving tabindex across the radios with ArrowLeft/ArrowRight/Up/Down/
* Home/End; the selected radio carries aria-checked. Selecting dispatches a
* `change` event ({ value }) so a consumer can react.
*
* Programmatic control: `window.UI.theme.set('dark')` (also get() and
* isDark()), or a window `set-theme` event whose detail is { theme } (a
* Livewire `$this->dispatch('set-theme', theme: 'dark')` or a command palette
* item). Every switcher on the page follows through the window
* `theme-changed` event ({ theme, dark }), which fires after each change.
*
* All localStorage access is wrapped in try/catch so a blocked/unavailable
* store (private mode, SSR-style prerender, file://) never throws.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
const THEMES = ['light', 'dark', 'system'];
// One OS-preference listener for the page, attached only while the theme is
// 'system', so the choice live-tracks the OS without a listener per switcher.
let systemQuery = null;
let onSystemChange = null;
function readStoredTheme() {
try {
const v = window.localStorage.getItem('theme');
return v === 'light' || v === 'dark' || v === 'system' ? v : null;
} catch (e) {
return null;
}
}
function writeStoredTheme(v) {
try {
window.localStorage.setItem('theme', v);
} catch (e) {
// Storage unavailable — keep working in-memory for this session.
}
}
function prefersDark() {
return typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let currentTheme = null;
const theme = {
/** The chosen theme: 'light', 'dark' or 'system'. */
get() {
return currentTheme ?? readStoredTheme() ?? 'system';
},
/** True when the effective theme is dark. */
isDark(v = theme.get()) {
return v === 'dark' || (v === 'system' && prefersDark());
},
/** Applies `.dark` and color-scheme on <html> for the chosen theme. */
apply(v = theme.get()) {
const root = document.documentElement;
if (root) {
const dark = theme.isDark(v);
root.classList.toggle('dark', dark);
root.style.colorScheme = dark ? 'dark' : 'light';
}
theme.syncSystemListener(v);
},
/** Chooses, persists and applies a theme. Returns false for an unknown value. */
set(v) {
if (!THEMES.includes(v)) return false;
currentTheme = v;
writeStoredTheme(v);
theme.apply(v);
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme: v, dark: theme.isDark(v) } }));
return true;
},
syncSystemListener(v = theme.get()) {
if (v === 'system') {
if (systemQuery || typeof window.matchMedia !== 'function') return;
systemQuery = window.matchMedia('(prefers-color-scheme: dark)');
onSystemChange = () => theme.apply('system');
systemQuery.addEventListener('change', onSystemChange);
} else if (systemQuery) {
systemQuery.removeEventListener('change', onSystemChange);
systemQuery = null;
onSystemChange = null;
}
},
};
window.UI = window.UI ?? {};
window.UI.theme = theme;
if (!window.__brokSetThemeListener) {
window.__brokSetThemeListener = true;
window.addEventListener('set-theme', (event) => {
const detail = Array.isArray(event.detail) ? event.detail[0] : event.detail;
theme.set(typeof detail === 'string' ? detail : detail?.theme);
});
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiThemeSwitcher', () => ({
value: 'system',
_onThemeChanged: null,
init() {
this.value = theme.get();
theme.apply(this.value);
// Another switcher, UI.theme.set() or a set-theme event changed it.
this._onThemeChanged = (event) => {
if (THEMES.includes(event.detail?.theme)) this.value = event.detail.theme;
};
window.addEventListener('theme-changed', this._onThemeChanged);
},
// --- selection -----------------------------------------------------
set(v) {
if (!theme.set(v)) return;
this.value = v;
this.focusActive();
this.$dispatch('change', { value: v });
},
// --- keyboard ------------------------------------------------------
radios() {
return Array.from(this.$el.querySelectorAll('[role="radio"]'));
},
focusActive() {
this.$nextTick(() => {
const btn = this.radios().find((b) => b.dataset.value === this.value);
if (btn) btn.focus();
});
},
onKey(event) {
const order = THEMES;
const last = order.length - 1;
const current = order.indexOf(this.value);
let next = null;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
next = current >= last ? 0 : current + 1;
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
next = current <= 0 ? last : current - 1;
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = last;
}
if (next === null) return;
event.preventDefault();
this.set(order[next]);
},
destroy() {
if (this._onThemeChanged) window.removeEventListener('theme-changed', this._onThemeChanged);
this._onThemeChanged = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.0 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0