Theme Script
A head tag pair that applies the stored light, dark or system theme before the first paint: a color-scheme meta and a blocking same-origin script, so dark mode never flashes light and a strict script-src 'self' policy needs no nonce.
Preview
Choose Dark, then reload: the stored theme is applied before the page paints, so there is no light flash.
{{-- In an app the theme script goes in <head>, before @vite. Here it sits next
to the theme switcher it pairs with: choose Dark, reload the frame, and the
first paint is already dark. --}}
<div class="flex flex-col items-start gap-4">
<x-ui.theme-script />
<x-ui.theme-switcher />
<p class="max-w-sm text-sm text-muted-foreground">{{ __('Choose Dark, then reload: the stored theme is applied before the page paints, so there is no light flash.') }}</p>
</div>
Installation
php artisan ui:add theme-script
Note
This component ships an Alpine behavior module at
resources/js/ui/theme-script.js. Import it once from your bundle so it registers on alpine:init:
import './theme-script.js';
Registry contract
php artisan ui:add theme-script
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-script.blade.php -
public/vendor/brok/theme-boot.js
- Registry dependencies
- None — installs on its own.
- 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.
# Brok UI: Theme Script (`theme-script`)
A head tag pair that applies the stored light, dark or system theme before the first paint: a color-scheme meta and a blocking same-origin script, so dark mode never flashes light and a strict script-src 'self' policy needs no nonce.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add theme-script
```
## Usage
```blade
{{-- In an app the theme script goes in <head>, before @vite. Here it sits next
to the theme switcher it pairs with: choose Dark, reload the frame, and the
first paint is already dark. --}}
<div class="flex flex-col items-start gap-4">
<x-ui.theme-script />
<x-ui.theme-switcher />
<p class="max-w-sm text-sm text-muted-foreground">{{ __('Choose Dark, then reload: the stored theme is applied before the page paints, so there is no light flash.') }}</p>
</div>
```
## Props
- `src` (string|null, default `null`) — URL of the boot script; null uses asset('vendor/brok/theme-boot.js'), where ui:add installs it. Set it when the app serves public assets from a CDN or a versioned path.
- `nonce` (string|null, default `null`) — Not needed for script-src 'self'. When the app's policy uses nonces, pass it (for example :nonce="Vite::cspNonce()") and it is set on the script tag. Other attributes are forwarded to the script tag as well.
## Use when
- Use to document, inspect, and apply design-system decisions consistently across teams and AI tooling.
- The app offers dark mode (theme-switcher) and a full page load must not paint light first.
- The Content-Security-Policy allows only same-origin scripts (script-src 'self'), so an inline boot script is not an option.
## Avoid when
- Do not present token-level controls to end users who only need product functionality.
- The app has no dark mode, or forces one theme with a fixed class on <html>; then there is nothing to restore before paint.
- The theme comes only from theme-customizer, which keeps its own storage key; this script reads the theme-switcher key.
- The page runs no script at all, for example an OAuth sign-in or consent page under default-src 'none': set data-theme="system" on <html> instead, and the tokens follow prefers-color-scheme without a script (see the theming docs). To keep the stored choice there, allow this script with a nonce (:nonce plus script-src 'nonce-…').
## Anti-patterns
- Exposing implementation tokens as product settings
## Rules
- Use the `<brok:theme-script>` tag (or `<x-ui.theme-script>`) 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-script
- Registry JSON (files, props, contract): https://brokui.dev/r/open/theme-script.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
{{-- The script renders no visible content; the long copy checks that the
switcher and its explanation wrap in a narrow column. --}}
<div class="flex max-w-xs flex-col items-start gap-4">
<x-ui.theme-script />
<x-ui.theme-switcher />
<p class="text-sm text-muted-foreground">{{ __('The stored choice is read from localStorage before the first paint, so a visitor who picked Dark sees a dark page on every reload and navigation, even on a slow connection where the stylesheet and the app bundle arrive late.') }}</p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | null | null | URL of the boot script; null uses asset('vendor/brok/theme-boot.js'), where ui:add installs it. Set it when the app serves public assets from a CDN or a versioned path. |
| nonce | string | null | null | Not needed for script-src 'self'. When the app's policy uses nonces, pass it (for example :nonce="Vite::cspNonce()") and it is set on the script tag. Other attributes are forwarded to the script tag as well. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders <meta name="color-scheme" content="light dark"> and a classic <script src> with no defer, async or module type, so it runs while the parser is still in <head>.
- The script reads localStorage key theme (light, dark or system). System, a missing or an unknown value follows prefers-color-scheme: dark. It toggles .dark on <html> and sets style.colorScheme to match, the same rule as theme-switcher.js.
- Storage access is wrapped in try/catch: blocked storage falls back to the system preference.
- Place it in <head> before @vite (or any stylesheet link). A script after a stylesheet waits for that stylesheet to download, and the theme must be set before the first paint.
- Under a strict Content-Security-Policy pass :nonce="..." with a matching script-src 'nonce-…', or allow script-src 'self'. Do not combine it with data-theme="system" on <html>: that attribute is the script-free mode and would keep a dark system setting over a stored light choice.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, 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.
- The app offers dark mode (theme-switcher) and a full page load must not paint light first.
- The Content-Security-Policy allows only same-origin scripts (script-src 'self'), so an inline boot script is not an option.
Avoid when
- Do not present token-level controls to end users who only need product functionality.
- The app has no dark mode, or forces one theme with a fixed class on <html>; then there is nothing to restore before paint.
- The theme comes only from theme-customizer, which keeps its own storage key; this script reads the theme-switcher key.
- The page runs no script at all, for example an OAuth sign-in or consent page under default-src 'none': set data-theme="system" on <html> instead, and the tokens follow prefers-color-scheme without a script (see the theming docs). To keep the stored choice there, allow this script with a nonce (:nonce plus script-src 'nonce-…').
Use instead
- Product-level controls for end users
Anti-patterns
- Exposing implementation tokens as product settings
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- Visitors who chose dark mode, often for light sensitivity, get no bright flash on navigation or reload.
- Native controls (scrollbars, form widgets, autofill) follow the resolved scheme from the first frame through color-scheme.
- 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-script-{{ $record->id }}">
{{-- In an app the theme script goes in <head>, before @vite. Here it sits next
to the theme switcher it pairs with: choose Dark, reload the frame, and the
first paint is already dark. --}}
<div class="flex flex-col items-start gap-4">
<x-ui.theme-script />
<x-ui.theme-switcher />
<p class="max-w-sm text-sm text-muted-foreground">{{ __('Choose Dark, then reload: the stored theme is applied before the page paints, so there is no light flash.') }}</p>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'src' => null,
'nonce' => null,
])
@php
$src ??= asset('vendor/brok/theme-boot.js');
@endphp
{{-- Theme script: place it in <head> before the stylesheet. The meta tells the
browser the page supports both schemes; the blocking file script sets
`.dark` on <html> from the stored choice before the first paint. --}}
<meta name="color-scheme" content="light dark">
<script data-slot="theme-script" src="{{ $src }}" @if (filled($nonce)) nonce="{{ $nonce }}" @endif {{ $attributes }}></script>
/**
* Theme boot: applies the stored light / dark / system choice before the first
* paint, so a dark page never flashes light on a full page load.
*
* A classic, blocking script (no module, no Alpine) that `<x-ui.theme-script>`
* loads from `public/vendor/brok/theme-boot.js` in `<head>`, before the
* stylesheet. It is a file, not an inline script, so it runs under a strict
* Content-Security-Policy (`script-src 'self'`, no nonce, no 'unsafe-inline').
*
* The rule is the same as theme-switcher.js (the two files cannot import each
* other, and ThemeScriptTest fails when they drift):
* - localStorage key "theme" holds 'light', 'dark' or 'system';
* - 'system', a missing or an unknown value follows
* matchMedia('(prefers-color-scheme: dark)');
* - `.dark` is toggled on <html> and `style.colorScheme` is set to match.
*
* All localStorage access is wrapped in try/catch, so blocked storage (private
* mode, file://) falls back to the system preference instead of throwing.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
(function () {
let value = null;
try {
const v = window.localStorage.getItem('theme');
value = v === 'light' || v === 'dark' || v === 'system' ? v : null;
} catch (e) {
value = null;
}
const mode = value ?? 'system';
const prefersDark = typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = mode === 'dark' || (mode === 'system' && prefersDark);
const root = document.documentElement;
root.classList.toggle('dark', dark);
root.style.colorScheme = dark ? 'dark' : 'light';
})();
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - 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