Skip to content
Brok UI

Loading…

No results

Theme Customizer

Open source

A self-contained slide-out theming panel: end users pick a colour preset, tune the corner radius, and toggle light/dark — applied live by injecting CSS variables and persisted to localStorage.

Version
v1.3.8
Stability
stable
License
MIT
Related
Theme Switcher
Color Palette

Preview

Side
previews.components.theme-customizer.default.blade.php Blade
<div class="flex min-h-48 items-start justify-end">
    <x-ui.theme-customizer />
</div>
End Current
Top Current
Right Current
Bottom Current
Left Current

Installation

terminal
php artisan ui:add theme-customizer

Note

This component ships an Alpine behavior module at resources/js/ui/theme-customizer.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './theme-customizer.js';

Registry contract

php artisan ui:add theme-customizer 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-customizer.blade.php
  • js resources/js/ui/theme-customizer.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

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-customizer.md
# Brok UI: Theme Customizer (`theme-customizer`)

A self-contained slide-out theming panel: end users pick a colour preset, tune the corner radius, and toggle light/dark — applied live by injecting CSS variables and persisted to localStorage.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add theme-customizer
```

## Usage

```blade
<div class="flex min-h-48 items-start justify-end">
    <x-ui.theme-customizer />
</div>
```

## Props

- `side` (string, default `end`) — Which edge the drawer slides in from ("end" or "start").

## Use when

- Use to document, inspect, and apply design-system decisions consistently across teams and AI tooling.
- Shipping an end-user-facing theming panel so visitors can pick a colour preset, tune the corner radius and switch light/dark on a live app.
- Letting a marketing or demo site show off runtime theming without building a settings page.

## Avoid when

- Do not present token-level controls to end users who only need product functionality.
- Only switching light/dark/system for the current visitor; use the lighter theme-switcher instead.
- Editing the app's own design tokens as a developer; that belongs in source, not an end-user panel.

## Anti-patterns

- Exposing implementation tokens as product settings

## Rules

- Use the `<brok:theme-customizer>` tag (or `<x-ui.theme-customizer>`) 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-customizer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/theme-customizer.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
side string end Which edge the drawer slides in from ("end" or "start").

Slots

  • default — Not used by this component; present for attribute-bag compatibility.

Data slots

Stable hooks for CSS overrides and browser tests.

theme-customizer theme-customizer-panel

Behavior

  • A trigger button opens a slide-out drawer; picking a preset, dragging the radius slider or picking light/dark re-themes the page live by injecting CSS variables, and the choice persists to localStorage.
  • Reopening the panel, or reloading the page, restores the last-saved preset, radius and mode from localStorage.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

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.
  • Shipping an end-user-facing theming panel so visitors can pick a colour preset, tune the corner radius and switch light/dark on a live app.
  • Letting a marketing or demo site show off runtime theming without building a settings page.

Avoid when

  • Do not present token-level controls to end users who only need product functionality.
  • Only switching light/dark/system for the current visitor; use the lighter theme-switcher instead.
  • Editing the app's own design tokens as a developer; that belongs in source, not an end-user panel.

Use instead

  • Product-level controls for end users

Anti-patterns

  • Exposing implementation tokens as product settings
Anatomy
root theme-customizer-panel
Theming hooks
theme-customizer theme-customizer-panel

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Escape
Focus
managed
  • The drawer is a role="dialog" with aria-modal="true", closes on Escape, and traps Tab focus inside itself while open.
  • The trigger button exposes aria-expanded and aria-haspopup="dialog" so assistive tech tracks the panel's open state.
  • 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:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="flex min-h-48 items-start justify-end">
        <x-ui.theme-customizer />
    </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-customizer
        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-customizer.blade.php Blade
@props([
    'side' => 'end',
])

{{--
    Theme Customizer — a self-contained slide-out theming panel a consumer drops
    into their app so END USERS can re-theme it live. A palette trigger opens a
    drawer with colour presets, a radius slider and a light/dark toggle. Every
    change re-themes the app by injecting CSS variables (see theme-customizer.js)
    and is persisted to localStorage. No literal colours live here — the preview
    dot colour is computed from each preset's channels via an Alpine `:style` bind.
--}}
<div
    x-data="uiThemeCustomizer()"
    data-slot="theme-customizer"
    {{ $attributes->merge(['class' => 'inline-block']) }}
>
    <x-ui.button
        type="button"
        variant="outline"
        size="icon"
        x-ref="trigger"
        x-on:click="show()"
        x-bind:aria-expanded="open ? 'true' : 'false'"
        aria-haspopup="dialog"
        aria-label="{{ __('Customize theme') }}"
    >
        <svg
            class="size-4 shrink-0"
            viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
        >
            <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
            <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
            <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
            <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
            <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2Z" />
        </svg>
    </x-ui.button>

    {{-- Scrim --}}
    <div
        x-show="open"
        x-transition.opacity.duration.150ms
        x-on:click="hide()"
        class="fixed inset-0 z-overlay bg-scrim/40 motion-reduce:transition-none"
        aria-hidden="true"
        style="display: none"
    ></div>

    {{-- Drawer --}}
    <div
        x-show="open"
        x-cloak
        x-ref="panel"
        tabindex="-1"
        x-on:keydown.escape.prevent.stop="hide()"
        x-on:keydown.tab.prevent="trap($event)"
        x-transition:enter="transition ease-out duration-200 motion-reduce:transition-none"
        x-transition:enter-start="translate-x-full rtl:-translate-x-full motion-reduce:translate-x-0 opacity-0"
        x-transition:enter-end="translate-x-0 opacity-100"
        x-transition:leave="transition ease-in duration-150 motion-reduce:transition-none"
        x-transition:leave-start="translate-x-0 opacity-100"
        x-transition:leave-end="translate-x-full rtl:-translate-x-full motion-reduce:translate-x-0 opacity-0"
        data-slot="theme-customizer-panel"
        role="dialog"
        aria-modal="true"
        aria-label="{{ __('Theme customizer') }}"
        @class([
            'fixed inset-y-0 z-modal flex w-[20rem] max-w-[calc(100vw-2rem)] flex-col gap-6 overflow-y-auto border-input bg-background p-6 text-foreground shadow-lg',
            'end-0 border-s' => $side === 'end',
            'start-0 border-e' => $side === 'start',
        ])
        style="display: none"
    >
        <header class="flex items-start justify-between gap-4">
            <div>
                <h2 class="text-base font-semibold leading-none tracking-tight">{{ __('Customize') }}</h2>
                <p class="mt-2 text-sm text-muted-foreground">{{ __('Pick a colour, radius and mode.') }}</p>
            </div>
            <x-ui.button
                type="button"
                variant="ghost"
                size="icon"
                x-on:click="hide()"
                aria-label="{{ __('Close') }}"
            >
                <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M18 6 6 18M6 6l12 12" />
                </svg>
            </x-ui.button>
        </header>

        {{-- Presets --}}
        <section class="flex flex-col gap-3">
            <h3 class="text-sm font-medium text-muted-foreground">{{ __('Colour') }}</h3>
            <div class="grid grid-cols-2 gap-2" role="group" aria-label="{{ __('Colour presets') }}">
                <template x-for="preset in presets" :key="preset.key">
                    <button
                        type="button"
                        x-on:click="setPreset(preset.key)"
                        :aria-pressed="active === preset.key ? 'true' : 'false'"
                        class="flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-ring motion-reduce:transition-none"
                    >
                        <span
                            class="size-4 shrink-0 rounded-full border border-border"
                            :style="`background-color: hsl(${preset.swatch})`"
                            aria-hidden="true"
                        ></span>
                        <span x-text="preset.label"></span>
                    </button>
                </template>
            </div>
        </section>

        {{-- Radius --}}
        <section class="flex flex-col gap-3">
            <div class="flex items-center justify-between gap-2">
                <label for="ui-theme-customizer-radius" class="text-sm font-medium text-muted-foreground">{{ __('Radius') }}</label>
                <span class="text-sm tabular-nums text-foreground" x-text="radiusLabel()"></span>
            </div>
            <input
                id="ui-theme-customizer-radius"
                type="range"
                min="0"
                max="1.5"
                step="0.025"
                x-model.number="radius"
                x-on:input="setRadius()"
                class="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary accent-primary focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
                :aria-valuetext="radiusLabel()"
            />
        </section>

        {{-- Mode --}}
        <section class="flex flex-col gap-3">
            <h3 class="text-sm font-medium text-muted-foreground">{{ __('Mode') }}</h3>
            <div class="grid grid-cols-2 gap-1 rounded-md border border-input bg-muted p-1" role="group" aria-label="{{ __('Colour mode') }}">
                <button
                    type="button"
                    x-on:click="setMode('light')"
                    :aria-pressed="mode === 'light' ? 'true' : 'false'"
                    class="inline-flex items-center justify-center gap-2 rounded-sm px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm motion-reduce:transition-none"
                >
                    <svg class="size-4 shrink-0" 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 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
                    </svg>
                    {{ __('Light') }}
                </button>
                <button
                    type="button"
                    x-on:click="setMode('dark')"
                    :aria-pressed="mode === 'dark' ? 'true' : 'false'"
                    class="inline-flex items-center justify-center gap-2 rounded-sm px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm motion-reduce:transition-none"
                >
                    <svg class="size-4 shrink-0" 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>
                    {{ __('Dark') }}
                </button>
            </div>
        </section>

        <footer class="mt-auto">
            <x-ui.button
                type="button"
                variant="outline"
                class="w-full"
                x-on:click="reset()"
            >
                {{ __('Reset to defaults') }}
            </x-ui.button>
        </footer>
    </div>
</div>
resources/js/ui/theme-customizer.js JS
/**
 * Theme Customizer behavior.
 *
 * A self-contained live-theming panel. End users pick a colour preset, tune the
 * corner radius and toggle light/dark; the choice re-themes the whole app and is
 * persisted to localStorage so it survives reloads.
 *
 * Theming is applied with ZERO runtime dependency on any package CSS/JS: on every
 * change we (re)write a single `<style id="ui-theme-customizer-vars">` element in
 * `<head>` that overrides the `--primary` / `--primary-foreground` / `--ring` /
 * `--radius` design tokens for the root and pinned admin/customer surfaces.
 * Light/dark mode itself is the `.dark` class on `<html>`.
 *
 * Generated presets carry their primary / primary-foreground / ring channels as
 * "H S% L%" strings for both light and dark, plus a default radius. `swatch` is the
 * light primary channel, used to paint the preview dot (via an Alpine `:style`
 * binding in the Blade, so no literal colour ever lives in the markup).
 *
 * Self-registers on `alpine:init` so import order does not matter — auto-imported
 * by the bundle's glob. Escape and outside-click are wired via Alpine directives in
 * the Blade, so this file adds no global listeners to leak.
 */
document.addEventListener('alpine:init', () => {
    const STORAGE_KEY = 'ui-theme-customizer';
    const STYLE_ID = 'ui-theme-customizer-vars';
    const LIGHT_SELECTORS = ":root,[data-surface='admin'],[data-surface='customer']";
    const DARK_SELECTORS = ".dark,.dark [data-surface='admin'],.dark[data-surface='admin'],.dark [data-surface='customer'],.dark[data-surface='customer']";
    const FORCED_LIGHT_SELECTORS = ".light,.light [data-surface='admin'],.light[data-surface='admin'],.light [data-surface='customer'],.light[data-surface='customer']";

    // <design-tokens:presets>
    // Generated by scripts/design-tokens.mjs from tokens/brands/.
    const PRESETS = [
            {
                    "key": "default",
                    "label": "Default",
                    "swatch": "222.2 47.4% 11.2%",
                    "radius": 1.5,
                    "light": {
                            "primary": "222.2 47.4% 11.2%",
                            "primaryForeground": "210 40% 98%",
                            "ring": "222.2 84% 4.9%"
                    },
                    "dark": {
                            "primary": "210 40% 98%",
                            "primaryForeground": "222.2 47.4% 11.2%",
                            "ring": "212.7 26.8% 83.9%"
                    }
            },
            {
                    "key": "zinc",
                    "label": "Zinc",
                    "swatch": "240 6% 10%",
                    "radius": 0.5,
                    "light": {
                            "primary": "240 6% 10%",
                            "primaryForeground": "0 0% 98%",
                            "ring": "240 6% 10%"
                    },
                    "dark": {
                            "primary": "0 0% 98%",
                            "primaryForeground": "240 6% 10%",
                            "ring": "240 5% 84%"
                    }
            },
            {
                    "key": "slate",
                    "label": "Slate",
                    "swatch": "215 25% 27%",
                    "radius": 0.75,
                    "light": {
                            "primary": "215 25% 27%",
                            "primaryForeground": "210 40% 98%",
                            "ring": "215 25% 27%"
                    },
                    "dark": {
                            "primary": "210 40% 90%",
                            "primaryForeground": "222 47% 11%",
                            "ring": "210 40% 80%"
                    }
            },
            {
                    "key": "rose",
                    "label": "Rose",
                    "swatch": "346.8 77.2% 48%",
                    "radius": 1,
                    "light": {
                            "primary": "346.8 77.2% 48%",
                            "primaryForeground": "355.7 100% 97.3%",
                            "ring": "346.8 77.2% 48%"
                    },
                    "dark": {
                            "primary": "346.8 77.2% 48%",
                            "primaryForeground": "355.7 100% 97.3%",
                            "ring": "346.8 77.2% 48%"
                    }
            },
            {
                    "key": "emerald",
                    "label": "Emerald",
                    "swatch": "142.1 76.2% 28%",
                    "radius": 1,
                    "light": {
                            "primary": "142.1 76.2% 28%",
                            "primaryForeground": "355.7 100% 97.3%",
                            "ring": "142.1 76.2% 28%"
                    },
                    "dark": {
                            "primary": "142.1 70.6% 45.3%",
                            "primaryForeground": "144.9 80.4% 10%",
                            "ring": "142.1 70.6% 45.3%"
                    }
            },
            {
                    "key": "violet",
                    "label": "Violet",
                    "swatch": "262.1 83.3% 57.8%",
                    "radius": 1.25,
                    "light": {
                            "primary": "262.1 83.3% 57.8%",
                            "primaryForeground": "210 20% 98%",
                            "ring": "262.1 83.3% 57.8%"
                    },
                    "dark": {
                            "primary": "263.4 70% 50.4%",
                            "primaryForeground": "210 20% 98%",
                            "ring": "263.4 70% 55%"
                    }
            },
            {
                    "key": "midnight",
                    "label": "Midnight",
                    "swatch": "224 76.3% 48%",
                    "radius": 0.75,
                    "light": {
                            "primary": "224 76.3% 48%",
                            "primaryForeground": "210 40% 98%",
                            "ring": "224 76.3% 48%"
                    },
                    "dark": {
                            "primary": "217.2 91.2% 59.8%",
                            "primaryForeground": "222.2 47.4% 11.2%",
                            "ring": "217.2 91.2% 59.8%"
                    }
            },
            {
                    "key": "sunset",
                    "label": "Sunset",
                    "swatch": "24.6 95% 38%",
                    "radius": 1,
                    "light": {
                            "primary": "24.6 95% 38%",
                            "primaryForeground": "60 9.1% 97.8%",
                            "ring": "24.6 95% 38%"
                    },
                    "dark": {
                            "primary": "20.5 90.2% 48.2%",
                            "primaryForeground": "20 14.3% 4.1%",
                            "ring": "20.5 90.2% 48.2%"
                    }
            },
            {
                    "key": "spotify",
                    "label": "Spotify",
                    "swatch": "141.6 73.4% 30%",
                    "radius": 2,
                    "light": {
                            "primary": "141.6 73.4% 30%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "141.6 73.4% 30%"
                    },
                    "dark": {
                            "primary": "141.9 69.2% 58%",
                            "primaryForeground": "141 80% 8%",
                            "ring": "141.9 69.2% 58%"
                    }
            },
            {
                    "key": "neo",
                    "label": "Neo",
                    "swatch": "0 0% 4%",
                    "radius": 0,
                    "light": {
                            "primary": "0 0% 4%",
                            "primaryForeground": "0 0% 98%",
                            "ring": "0 0% 4%"
                    },
                    "dark": {
                            "primary": "0 0% 98%",
                            "primaryForeground": "0 0% 4%",
                            "ring": "0 0% 98%"
                    }
            },
            {
                    "key": "claude",
                    "label": "Claude",
                    "swatch": "15.2 63.1% 59.2%",
                    "radius": 0.75,
                    "light": {
                            "primary": "15.2 63.1% 59.2%",
                            "primaryForeground": "20 50% 10%",
                            "ring": "15.2 63.1% 52%"
                    },
                    "dark": {
                            "primary": "15.2 55.4% 52.5%",
                            "primaryForeground": "20 50% 10%",
                            "ring": "15.2 55.4% 52.5%"
                    }
            },
            {
                    "key": "monogram",
                    "label": "Monogram",
                    "swatch": "0 0% 12.5%",
                    "radius": 0,
                    "light": {
                            "primary": "0 0% 12.5%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "0 0% 12.5%"
                    },
                    "dark": {
                            "primary": "0 0% 98%",
                            "primaryForeground": "0 0% 12.5%",
                            "ring": "0 0% 83.9%"
                    }
            },
            {
                    "key": "prestige",
                    "label": "Prestige",
                    "swatch": "0 0% 11%",
                    "radius": 0,
                    "light": {
                            "primary": "0 0% 11%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "0 0% 11%"
                    },
                    "dark": {
                            "primary": "0 0% 95%",
                            "primaryForeground": "0 0% 11%",
                            "ring": "0 0% 84%"
                    }
            },
            {
                    "key": "showcase",
                    "label": "Showcase",
                    "swatch": "240 9% 30%",
                    "radius": 0,
                    "light": {
                            "primary": "240 9% 30%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "240 9% 30%"
                    },
                    "dark": {
                            "primary": "240 9% 92%",
                            "primaryForeground": "240 9% 12%",
                            "ring": "240 9% 84%"
                    }
            },
            {
                    "key": "madrid",
                    "label": "Madrid",
                    "swatch": "0 0% 0%",
                    "radius": 0,
                    "light": {
                            "primary": "0 0% 0%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "0 0% 0%"
                    },
                    "dark": {
                            "primary": "0 0% 98%",
                            "primaryForeground": "0 0% 0%",
                            "ring": "0 0% 84%"
                    }
            },
            {
                    "key": "xclusive",
                    "label": "Xclusive",
                    "swatch": "0 0% 12%",
                    "radius": 0,
                    "light": {
                            "primary": "0 0% 12%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "0 0% 12%"
                    },
                    "dark": {
                            "primary": "0 0% 95%",
                            "primaryForeground": "0 0% 12%",
                            "ring": "0 0% 84%"
                    }
            },
            {
                    "key": "alchemy",
                    "label": "Alchemy",
                    "swatch": "240 7% 11%",
                    "radius": 0,
                    "light": {
                            "primary": "240 7% 11%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "240 7% 11%"
                    },
                    "dark": {
                            "primary": "0 0% 95%",
                            "primaryForeground": "240 7% 11%",
                            "ring": "0 0% 84%"
                    }
            },
            {
                    "key": "reformation",
                    "label": "Reformation",
                    "swatch": "228 31% 23%",
                    "radius": 0,
                    "light": {
                            "primary": "228 31% 23%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "228 31% 23%"
                    },
                    "dark": {
                            "primary": "228 30% 72%",
                            "primaryForeground": "228 30% 12%",
                            "ring": "228 30% 72%"
                    }
            },
            {
                    "key": "sampo",
                    "label": "Sampo",
                    "swatch": "30 8% 19%",
                    "radius": 0,
                    "light": {
                            "primary": "30 8% 19%",
                            "primaryForeground": "0 0% 100%",
                            "ring": "30 8% 19%"
                    },
                    "dark": {
                            "primary": "30 8% 82%",
                            "primaryForeground": "30 8% 12%",
                            "ring": "30 8% 82%"
                    }
            }
    ];
    // </design-tokens:presets>

    const DEFAULTS = { preset: 'default', radius: 0.5, mode: 'light' };

    window.Alpine.data('uiThemeCustomizer', () => ({
        presets: PRESETS,
        open: false,
        active: DEFAULTS.preset,
        radius: DEFAULTS.radius,
        mode: DEFAULTS.mode,

        previouslyFocused: null,
        scrollLocked: false,

        init() {
            this.restore();
            this.applyTheme();
        },

        // --- Drawer open/close + focus management (no Alpine focus plugin needed) ---

        lockScroll() {
            if (this.scrollLocked) return;
            window.__uiScrollLocks = (window.__uiScrollLocks || 0) + 1;
            document.body.style.overflow = 'hidden';
            this.scrollLocked = true;
        },

        unlockScroll() {
            if (!this.scrollLocked) return;
            window.__uiScrollLocks = Math.max(0, (window.__uiScrollLocks || 1) - 1);
            if (window.__uiScrollLocks === 0) document.body.style.overflow = '';
            this.scrollLocked = false;
        },

        show() {
            this.previouslyFocused = document.activeElement;
            this.open = true;
            this.lockScroll();
            this.$nextTick(() => {
                const els = this.focusables();
                (els[0] || this.$refs.panel)?.focus();
            });
        },

        hide() {
            if (!this.open) {
                return;
            }
            this.open = false;
            this.unlockScroll();
            const target = this.previouslyFocused || this.$refs.trigger;
            this.$nextTick(() => target?.focus());
        },

        focusables() {
            const panel = this.$refs.panel;
            if (!panel) {
                return [];
            }
            return Array.from(
                panel.querySelectorAll(
                    'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), iframe, object, embed, [contenteditable=true], [tabindex]:not([tabindex="-1"])',
                ),
            ).filter((el) => el.getClientRects().length > 0 && el.getAttribute('aria-hidden') !== 'true' && !el.closest('[inert]'));
        },

        // Trap Tab inside the panel; Tab is prevented in markup and focus moved here.
        trap(event) {
            const els = this.focusables();
            if (els.length === 0) {
                this.$refs.panel?.focus();
                return;
            }
            const active = document.activeElement;
            const index = els.indexOf(active);
            let next;
            if (event.shiftKey) {
                next = index <= 0 ? els[els.length - 1] : els[index - 1];
            } else {
                next = index === -1 || index === els.length - 1 ? els[0] : els[index + 1];
            }
            next.focus();
        },

        // Restore body scroll if torn down while open (e.g. SPA navigation).
        destroy() {
            this.unlockScroll();
        },

        current() {
            return this.presets.find((p) => p.key === this.active) || this.presets[0];
        },

        radiusLabel() {
            return `${Number(this.radius).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}rem`;
        },

        styleEl() {
            let el = document.getElementById(STYLE_ID);
            if (!el) {
                el = document.createElement('style');
                el.id = STYLE_ID;
                document.head.appendChild(el);
            }
            return el;
        },

        applyTheme() {
            const p = this.current();
            const r = `${this.radius}rem`;
            const light =
                `--primary:${p.light.primary};` +
                `--primary-color:hsl(${p.light.primary});` +
                `--primary-foreground:${p.light.primaryForeground};` +
                `--primary-foreground-color:hsl(${p.light.primaryForeground});` +
                `--ring:${p.light.ring};` +
                `--ring-color:hsl(${p.light.ring});` +
                `--radius:${r};`;
            const dark =
                `--primary:${p.dark.primary};` +
                `--primary-color:hsl(${p.dark.primary});` +
                `--primary-foreground:${p.dark.primaryForeground};` +
                `--primary-foreground-color:hsl(${p.dark.primaryForeground});` +
                `--ring:${p.dark.ring};` +
                `--ring-color:hsl(${p.dark.ring});`;
            this.styleEl().textContent =
                `${LIGHT_SELECTORS}{${light}}` +
                `${DARK_SELECTORS}{${dark}}` +
                `${FORCED_LIGHT_SELECTORS}{${light}}`;

            document.documentElement.classList.toggle('dark', this.mode === 'dark');
            document.documentElement.style.colorScheme = this.mode === 'dark' ? 'dark' : 'light';
            this.persist();
        },

        setPreset(key) {
            this.active = key;
            const p = this.current();
            this.radius = p.radius;
            this.applyTheme();
        },

        setRadius() {
            this.radius = Number(this.radius);
            this.applyTheme();
        },

        setMode(mode) {
            this.mode = mode;
            this.applyTheme();
        },

        reset() {
            this.active = DEFAULTS.preset;
            this.radius = DEFAULTS.radius;
            this.mode = DEFAULTS.mode;
            this.applyTheme();
        },

        persist() {
            try {
                localStorage.setItem(
                    STORAGE_KEY,
                    JSON.stringify({ preset: this.active, radius: this.radius, mode: this.mode }),
                );
            } catch (e) {
                /* storage unavailable (private mode / quota) — theme still applies for the session */
            }
        },

        restore() {
            let saved = null;
            try {
                saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
            } catch (e) {
                saved = null;
            }
            if (!saved) {
                return;
            }
            if (this.presets.some((p) => p.key === saved.preset)) {
                this.active = saved.preset;
            }
            if (typeof saved.radius === 'number' && isFinite(saved.radius)) {
                this.radius = Math.min(1.5, Math.max(0, saved.radius));
            }
            if (saved.mode === 'light' || saved.mode === 'dark') {
                this.mode = saved.mode;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.8
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