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.
Preview
<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
Tone options
Installation
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:
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.
-
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.
# 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
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
<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.
@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