Skip to content
Brok UI

Loading…

No results

Theme Script

Open source

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.

Version
v1.0.2
Stability
stable
License
MIT
Related
Theme Switcher

Preview

Choose Dark, then reload: the stored theme is applied before the page paints, so there is no light flash.

previews.components.theme-script.default.blade.php 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>

Installation

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

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

  • blade resources/views/components/ui/theme-script.blade.php
  • js 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.

theme-script.md
# 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

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

theme-script

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

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.
  • 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
meta theme-script
Theming hooks
color-scheme meta (light dark) .dark class and color-scheme on <html>, which the foundation tokens follow

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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-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.

resources/views/components/ui/theme-script.blade.php Blade
@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>
public/vendor/brok/theme-boot.js JS
/**
 * 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