Skip to content
Brok UI

Loading…

No results

Switch

Open source Core

Accessible switch control with RTL-aware thumb movement, sm/md/lg sizes, pill or rounded radius and an optional inline label. Forwards wire:model onto the underlying input and keeps aria-checked in sync with server-driven Livewire changes.

Version
v1.3.6
Stability
stable
License
MIT
Related
Checkbox
Swap
Switch Field

Preview

Size
Disabled
Tone
previews.components.switch.default.blade.php Blade
<div class="flex flex-col gap-5">
    <x-ui.switch checked>{{ __('Enable notifications') }}</x-ui.switch>

    <div class="flex items-center gap-5">
        <x-ui.switch size="sm" checked aria-label="{{ __('Small') }}" />
        <x-ui.switch size="md" checked aria-label="{{ __('Medium') }}" />
        <x-ui.switch size="lg" checked aria-label="{{ __('Large') }}" />
        <x-ui.switch radius="rounded" checked aria-label="{{ __('Rounded') }}" />
    </div>
</div>

Size options

Sm Current
Track Current
Thumb Current
Move Current
Md Current
Lg Current

Tone options

Neutral Current
Primary Current

Installation

terminal
php artisan ui:add switch

Note

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

resources/js/ui/index.js JS
import './switch.js';

Registry contract

php artisan ui:add switch 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/switch.blade.php
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.

switch.md
# Brok UI: Switch (`switch`)

Accessible switch control with RTL-aware thumb movement, sm/md/lg sizes, pill or rounded radius and an optional inline label. Forwards wire:model onto the underlying input and keeps aria-checked in sync with server-driven Livewire changes.

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

## Install

```bash
php artisan ui:add switch
```

## Usage

```blade
<div class="flex flex-col gap-5">
    <x-ui.switch checked>{{ __('Enable notifications') }}</x-ui.switch>

    <div class="flex items-center gap-5">
        <x-ui.switch size="sm" checked aria-label="{{ __('Small') }}" />
        <x-ui.switch size="md" checked aria-label="{{ __('Medium') }}" />
        <x-ui.switch size="lg" checked aria-label="{{ __('Large') }}" />
        <x-ui.switch radius="rounded" checked aria-label="{{ __('Rounded') }}" />
    </div>
</div>
```

## Props

- `size` (md|sm|track|thumb|move|lg|default, default `md`) — Track and thumb dimension: sm, md, or lg.
- `radius` (string, default `pill`) — Track and thumb corner style: pill (fully rounded) or rounded.
- `label` (mixed|null, default `null`) — Inline label text rendered beside the track when the default slot is empty.
- `disabled` (bool, default `false`) — Disables interaction and dims the control.
- `invalid` (bool, default `false`) — Sets aria-invalid; also inferred from a wrapping field's validation errors.
- `tone` (neutral|primary, default `neutral`) — Fill of the checked track: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary.
- `checked` (bool, default `false`) — Initial on/off state.

## Use when

- Use for immediate state changes that are obvious and reversible.
- Toggling a setting that takes effect immediately, such as a preference or feature flag.
- Showing a labelled on/off state with a familiar track-and-thumb control.

## Avoid when

- Do not use for choices that need explanation, confirmation, or a later Save step.
- Only use when the user expects an immediate, obvious, and reversible state change.
- Selecting an item inside a form that requires an explicit submit action; use checkbox instead.
- Cross-fading between two icons with no labelled state; use swap.

## Anti-patterns

- Using a switch when Save is required
- Using a switch for irreversible actions

## Rules

- Use the `<brok:switch>` tag (or `<x-ui.switch>`) 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/switch
- Registry JSON (files, props, contract): https://brokui.dev/r/open/switch.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

tones.blade.php Blade
<div class="flex flex-col gap-4">
    <x-ui.switch checked>{{ __('Email me a weekly digest (neutral, the default)') }}</x-ui.switch>
    <x-ui.switch tone="primary" checked>{{ __('Email me a weekly digest (primary)') }}</x-ui.switch>
</div>

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 | track | thumb | move | lg | default md Track and thumb dimension: sm, md, or lg.
radius string pill Track and thumb corner style: pill (fully rounded) or rounded.
label mixed | null null Inline label text rendered beside the track when the default slot is empty.
disabled bool false Disables interaction and dims the control.
invalid bool false Sets aria-invalid; also inferred from a wrapping field's validation errors.
tone neutral | primary neutral Fill of the checked track: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary.
checked bool false Initial on/off state.

Slots

  • default — Label content rendered beside the track, taking priority over the label prop.

Data slots

Stable hooks for CSS overrides and browser tests.

switch

Behavior

  • A native checkbox with role="switch" is the source of truth for state, keyboard (space), form value, and peer-checked track/thumb styling.
  • A small Alpine wrapper keeps aria-checked in sync even when Livewire's wire:model writes the control from the server.
  • Inside a wrapping field with a name, the switch self-wires its id, aria-invalid, and aria-describedby off the shared error bag.
  • tone="neutral" (the default) fills the checked track with the foreground colour; tone="primary" uses the brand primary. The default palette looks the same either way, because its primary is the text colour.
  • Mirrors state into an accessible switch-style control and forwards name/value attributes for forms.
  • 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

Immediate reversible state

Change an immediate reversible system state.

Use when

  • Use for immediate state changes that are obvious and reversible.
  • Toggling a setting that takes effect immediately, such as a preference or feature flag.
  • Showing a labelled on/off state with a familiar track-and-thumb control.

Avoid when

  • Do not use for choices that need explanation, confirmation, or a later Save step.
  • Only use when the user expects an immediate, obvious, and reversible state change.
  • Selecting an item inside a form that requires an explicit submit action; use checkbox instead.
  • Cross-fading between two icons with no labelled state; use swap.

Use instead

  • Checkbox for a value saved later
  • Button plus confirmation for consequential actions

Anti-patterns

  • Using a switch when Save is required
  • Using a switch for irreversible actions
Anatomy
root
Theming hooks
switch

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • The control exposes role="switch" with a live aria-checked attribute that tracks both user and server-driven changes.
  • The thumb moves in the correct direction under dir="rtl" because its translate utilities are RTL-aware.
  • 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="switch-{{ $record->id }}">
    <div class="flex flex-col gap-5">
        <x-ui.switch checked>{{ __('Enable notifications') }}</x-ui.switch>
    
        <div class="flex items-center gap-5">
            <x-ui.switch size="sm" checked aria-label="{{ __('Small') }}" />
            <x-ui.switch size="md" checked aria-label="{{ __('Medium') }}" />
            <x-ui.switch size="lg" checked aria-label="{{ __('Large') }}" />
            <x-ui.switch radius="rounded" checked aria-label="{{ __('Rounded') }}" />
        </div>
    </div>
</div>

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:switch
        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 file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/switch.blade.php Blade
@aware([
    'name' => null,
    'htmlName' => null,
    'required' => false,
])

@props([
    'size' => 'md',
    'radius' => 'pill',
    'label' => null,
    'disabled' => false,
    'invalid' => false,
    // Fill of the checked track. neutral (default) uses the foreground colour,
    // so a product that keeps primary for its one main action and for focus is
    // not diluted; primary uses the brand primary.
    'tone' => 'neutral',
])

@php
    // Full class strings per size so Tailwind's JIT can see every utility — never
    // splice the translate distance into an arbitrary class.
    $sizes = [
        'sm' => ['track' => 'h-5 w-9', 'thumb' => 'size-4', 'move' => 'peer-checked:translate-x-4 rtl:peer-checked:-translate-x-4'],
        'md' => ['track' => 'h-6 w-11', 'thumb' => 'size-5', 'move' => 'peer-checked:translate-x-5 rtl:peer-checked:-translate-x-5'],
        'lg' => ['track' => 'h-7 w-14', 'thumb' => 'size-6', 'move' => 'peer-checked:translate-x-7 rtl:peer-checked:-translate-x-7'],
    ];
    // 'default' is shadcn's canonical token; alias it to md (additive, never throws).
    $size = $size === 'default' ? 'md' : (isset($sizes[$size]) ? $size : 'md');
    $s = $sizes[$size];

    $radius = $radius === 'rounded' ? 'rounded' : 'pill';
    $trackRadius = $radius === 'rounded' ? 'rounded-md' : 'rounded-full';
    $thumbRadius = $radius === 'rounded' ? 'rounded-sm' : 'rounded-full';

    // Full class strings per tone so Tailwind's JIT can see every utility.
    $tones = [
        'neutral' => 'peer-checked:bg-foreground',
        'primary' => 'peer-checked:bg-primary',
    ];
    $tone = isset($tones[$tone]) ? $tone : 'neutral';

    $hasLabel = $label !== null || ! $slot->isEmpty();

    // Laravel-native wiring (mirrors input/checkbox). Inert with no field ancestor
    // and no own name — fully backward-compatible. Checked-state restoration stays
    // with the consumer (@checked / wire:model).
    $awareName = $name ?? null;
    $awareHtmlName = $htmlName ?? null;
    $fieldName = $awareName ?? $attributes->get('name');
    $resolvedHtmlName = $awareName
        ? ($awareHtmlName ?? collect(explode('.', $awareName))->reduce(
            fn ($carry, $segment, $index) => $index === 0 ? $segment : $carry.'['.$segment.']',
            ''
        ))
        : $attributes->get('name');
    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    $hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
    $isInvalid = $invalid || $hasBagError;
    $describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
    $resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
    $isRequired = (bool) ($required ?? false) || $attributes->has('required');
@endphp

{{-- A tiny inline Alpine wrapper exposes the native checkbox as role="switch"
     with a live aria-checked, while the input stays the source of truth for
     keyboard (space), form value and peer-checked styling. The `input` event
     also fires when Livewire writes the control via `wire:model`, so a
     server-driven change keeps `aria-checked` in sync — see /docs/livewire.
     The state is read again on the next tick because a consumer `x-model` /
     `x-bind:checked` on the input is applied after this label initialises. --}}
<label
    data-slot="switch"
    data-size="{{ $size }}"
    data-radius="{{ $radius }}"
    data-tone="{{ $tone }}"
    x-data="{ checked: false }"
    x-init="checked = $refs.control.checked; $nextTick(() => checked = $refs.control.checked)"
    class="inline-flex cursor-pointer items-center gap-2"
>
    {{-- Consumer `class` lands on the visible track (the control users size/skin),
         matching input/textarea where `class` reaches the primary control. --}}
    <span {{ $attributes->only('class')->merge(['class' => 'relative inline-flex shrink-0 items-center ' . $s['track']]) }}>
        <input
            type="checkbox"
            role="switch"
            class="peer sr-only"
            x-ref="control"
            x-bind:aria-checked="checked.toString()"
            @change="checked = $event.target.checked"
            @input="checked = $event.target.checked"
            @if (filled($resolvedHtmlName)) name="{{ $resolvedHtmlName }}" @endif
            @if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
            @disabled($disabled)
            @required($isRequired)
            @if ($isRequired) aria-required="true" @endif
            @if ($isInvalid) aria-invalid="true" @endif
            @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
            {{ $attributes->except(['class', 'name', 'id', 'required', 'aria-required']) }}
        />

        <span
            aria-hidden="true"
            class="absolute inset-0 bg-input transition-colors {{ $tones[$tone] }}
                peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background
                peer-disabled:cursor-not-allowed peer-disabled:opacity-50 {{ $trackRadius }}"
        ></span>

        <span
            aria-hidden="true"
            class="absolute start-0.5 bg-background shadow-sm transition-transform
                {{ $s['thumb'] }} {{ $thumbRadius }} {{ $s['move'] }} motion-reduce:transition-none"
        ></span>
    </span>

    @if ($hasLabel)
        <span class="select-none text-sm font-medium text-foreground">{{ $slot->isEmpty() ? __($label) : $slot }}</span>
    @endif
</label>

Ownership & lifecycle

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