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.
Preview
<div class="flex min-h-48 items-start justify-end">
<x-ui.theme-customizer />
</div>
Installation
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:
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.
-
resources/views/components/ui/theme-customizer.blade.php -
resources/js/ui/theme-customizer.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 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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 wire:ignore to the component root because its behavior owns rendered DOM.
<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.
<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.
@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>
/**
* 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