Skip to content
Brok UI

Loading…

No results

Theme Switcher

Open source

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.

Version
v1.2.0
Stability
stable
License
MIT
Related
Theme Customizer
Organization Switcher
Color Palette

Preview

Size
previews.components.theme-switcher.default.blade.php Blade
<div class="flex flex-col items-start gap-5">
    <x-ui.theme-switcher />

    <x-ui.theme-switcher :labels="false" aria-label="{{ __('Theme') }}" />
</div>
Sm Current
Btn Current
Icon Current
Md Current

Installation

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

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/theme-switcher.blade.php
  • js resources/js/ui/theme-switcher.js
Registry dependencies
theme-script
Packages
composer: jml/brok:^0.2

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.

theme-switcher.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

theme-switcher

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

Design-token documentation

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
root
Theming hooks
theme-switcher

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/theme-switcher.blade.php Blade
@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>
resources/js/ui/theme-switcher.js JS
/**
 * 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