Checkbox
Native checkbox with sm/md/lg sizes, indeterminate, invalid, disabled, dark, and RTL support. Optional animated variant with a spring-drawn SVG tick and strike-through label (reduced-motion safe).
Preview
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox id="preview-checkbox" checked />
{{ __('Accept terms and conditions') }}
</label>
Installation
php artisan ui:add checkbox
Registry contract
php artisan ui:add checkbox
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/checkbox.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: Checkbox (`checkbox`)
Native checkbox with sm/md/lg sizes, indeterminate, invalid, disabled, dark, and RTL support. Optional animated variant with a spring-drawn SVG tick and strike-through label (reduced-motion safe).
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add checkbox
```
## Usage
```blade
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox id="preview-checkbox" checked />
{{ __('Accept terms and conditions') }}
</label>
```
## Props
- `size` (md|sm|lg|default, default `md`) — Box dimension; sm and md render the same size, lg is larger.
- `indeterminate` (bool, default `false`) — Renders the native mixed state via aria-checked and JS, for a partially checked group.
- `invalid` (bool, default `false`) — Switches the accent to destructive and sets aria-invalid; also inferred from a wrapping field's validation errors.
- `disabled` (bool, default `false`) — Disables interaction and dims the control.
- `checked` (bool, default `false`) — Initial checked state.
## Use when
- Use for independent selections that can coexist.
- Capturing an independent true/false or a tri-state selection inside a form.
- Letting the user pick zero or more options from a related set.
## Avoid when
- Do not use for mutually exclusive choices; radio is clearer.
- Toggling a setting that applies immediately; use switch instead.
- Choosing exactly one option from a set; use radio.
## Anti-patterns
- Using checkboxes for mutually exclusive answers
## Rules
- Use the `<brok:checkbox>` tag (or `<x-ui.checkbox>`) 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/checkbox
- Registry JSON (files, props, contract): https://brokui.dev/r/open/checkbox.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 items-start gap-3 p-4">
<x-ui.checkbox animated label="Spring-drawn tick" checked />
<x-ui.checkbox animated label="Strikes through when done" />
<x-ui.checkbox animated label="Larger control" size="lg" />
<x-ui.checkbox animated label="Keep this one unchecked" :line-through="false" />
</div>
<fieldset class="grid max-w-md gap-3">
<legend class="sr-only">{{ __('Payment method') }}</legend>
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-input bg-card p-4 transition-colors has-[:checked]:border-primary has-[:checked]:bg-accent has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background">
<x-ui.checkbox checked />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Visa ending in 4242') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('Expires 04 / 2028') }}</span>
</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-input bg-card p-4 transition-colors has-[:checked]:border-primary has-[:checked]:bg-accent has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background">
<x-ui.checkbox />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Mastercard ending in 8210') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('Expires 11 / 2026') }}</span>
</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-input bg-card p-4 opacity-60 transition-colors">
<x-ui.checkbox :disabled="true" />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Amex ending in 1005') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('Unavailable for this order') }}</span>
</span>
</label>
</fieldset>
<div
class="max-w-sm space-y-3"
x-data="{
items: { read: true, write: false, delete: false },
get checkedCount() { return Object.values(this.items).filter(Boolean).length },
get total() { return Object.keys(this.items).length },
get allChecked() { return this.checkedCount === this.total },
get someChecked() { return this.checkedCount > 0 && ! this.allChecked },
toggleAll(value) { for (const key in this.items) this.items[key] = value },
}"
>
<label class="flex items-center gap-3 border-b border-border pb-3 text-sm font-medium text-foreground">
<x-ui.checkbox
x-bind:checked="allChecked"
x-bind:indeterminate="someChecked"
x-on:change="toggleAll($event.target.checked)"
/>
{{ __('Select all permissions') }}
</label>
<label class="flex items-start gap-3">
<x-ui.checkbox class="mt-0.5" x-model="items.read" />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Read') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('View records and reports.') }}</span>
</span>
</label>
<label class="flex items-start gap-3">
<x-ui.checkbox class="mt-0.5" x-model="items.write" />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Write') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('Create and edit records.') }}</span>
</span>
</label>
<label class="flex items-start gap-3">
<x-ui.checkbox class="mt-0.5" x-model="items.delete" />
<span class="space-y-0.5">
<span class="block text-sm font-medium text-foreground">{{ __('Delete') }}</span>
<span class="block text-sm text-muted-foreground">{{ __('Permanently remove records.') }}</span>
</span>
</label>
</div>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox size="sm" checked />
{{ __('Small') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox size="md" checked />
{{ __('Medium') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox size="lg" checked />
{{ __('Large') }}
</label>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox />
{{ __('Unchecked') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox checked />
{{ __('Checked') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox :indeterminate="true" />
{{ __('Indeterminate') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox :invalid="true" />
{{ __('Invalid') }}
</label>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<x-ui.checkbox :disabled="true" />
{{ __('Disabled') }}
</label>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<x-ui.checkbox :disabled="true" checked />
{{ __('Disabled checked') }}
</label>
</div>
Target Size
{{-- A row-selection checkbox outside a label, as in a table or an issue list.
The box draws at 16px, but an invisible hit area reaches 4px past it on
every side, so the pointer target is 24x24 (WCAG 2.5.8) and the row
layout does not move. --}}
<ul role="list" class="w-full max-w-md divide-y divide-border rounded-lg border border-border bg-card text-sm text-foreground">
@foreach ([['key' => 'ATL-12', 'title' => __('Icon set for the client portal')], ['key' => 'ATL-14', 'title' => __('Invoice PDF footer spacing')]] as $task)
<li class="flex items-center gap-3 px-4 py-2">
<x-ui.checkbox data-testid="target-{{ $task['key'] }}" :aria-label="__('Select :key', ['key' => $task['key']])" />
<span class="text-muted-foreground tabular-nums">{{ $task['key'] }}</span>
<span class="min-w-0 truncate">{{ $task['title'] }}</span>
</li>
@endforeach
</ul>
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox checked />
{{ __('Include archived projects (neutral, the default)') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox tone="primary" checked />
{{ __('Include archived projects (primary)') }}
</label>
<x-ui.checkbox animated checked :label="__('Send the invoice reminder (neutral, animated)')" />
<x-ui.checkbox animated tone="primary" checked :label="__('Send the invoice reminder (primary, animated)')" />
</div>
With Label
<x-ui.field class="max-w-sm">
<div class="flex items-start gap-3">
<x-ui.checkbox id="preview-newsletter" class="mt-0.5" checked />
<div class="space-y-1">
<x-ui.label for="preview-newsletter">{{ __('Subscribe to the newsletter') }}</x-ui.label>
<x-ui.field.description>
{{ __('Get product updates and tips once a week. Unsubscribe anytime.') }}
</x-ui.field.description>
</div>
</div>
</x-ui.field>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | md | sm | lg | default | md | Box dimension; sm and md render the same size, lg is larger. |
| indeterminate | bool | false | Renders the native mixed state via aria-checked and JS, for a partially checked group. |
| invalid | bool | false | Switches the accent to destructive and sets aria-invalid; also inferred from a wrapping field's validation errors. |
| disabled | bool | false | Disables interaction and dims the control. |
| checked | bool | false | Initial checked state. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Inside a wrapping field with a name, the checkbox self-wires its id, aria-invalid, and aria-describedby off the shared error bag.
- The animated variant keeps a real, screen-reader-only input as the source of truth for state, keyboard, and form submission, and draws a spring tick plus an optional strike-through label around it.
- Checked-state restoration after validation is left to the consumer via @checked or wire:model.
- tone="neutral" (the default) tints the checked box with the foreground colour; tone="primary" uses the brand primary. invalid still wins with the destructive colour.
- Uses a native checkbox input so form submission and keyboard behavior remain browser-owned.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Select independent options.
Use when
- Use for independent selections that can coexist.
- Capturing an independent true/false or a tri-state selection inside a form.
- Letting the user pick zero or more options from a related set.
Avoid when
- Do not use for mutually exclusive choices; radio is clearer.
- Toggling a setting that applies immediately; use switch instead.
- Choosing exactly one option from a set; use radio.
Use instead
- Radio for mutually exclusive choices
Anti-patterns
- Using checkboxes for mutually exclusive answers
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The indeterminate state is exposed through aria-checked="mixed" so assistive tech announces it correctly.
- The animated variant's visible box is aria-hidden and driven purely by the underlying native input, so keyboard and screen-reader behavior match the default control.
- The box draws at 16px, but an invisible ::before reaches 4px past it on every side, so the pointer target is at least 24x24 (WCAG 2.5.8 Target Size) without changing the drawn size or the layout. Firefox does not render ::before on a native control, so there the target stays 16px unless a clickable label wraps it.
- 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="checkbox-{{ $record->id }}">
<label class="flex items-center gap-2 text-sm text-foreground">
<x-ui.checkbox id="preview-checkbox" checked />
{{ __('Accept terms and conditions') }}
</label>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:checkbox
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.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware, so the
checkbox self-wires id + aria-invalid/aria-describedby off the shared $errors
bag (and its <label for> resolves). With no field ancestor and no own name it
stays inert — fully backward-compatible. Checked-state restoration is left to
the consumer (@checked / wire:model), the idiomatic Laravel/Livewire approach. --}}
@aware([
'name' => null,
'htmlName' => null,
'required' => false,
])
@props([
'size' => 'md',
'indeterminate' => false,
'invalid' => false,
'disabled' => false,
// Opt-in: a custom control whose SVG tick springs in on check (the draw +
// optional label strike live in ui.css, keyed on [data-variant="animated"]).
// Default (false) keeps the bare, native accent-tinted checkbox unchanged.
'animated' => false,
// Optional inline label rendered beside the animated box.
'label' => null,
// Strike the label through (and mute it) when checked. Only when `animated`.
'lineThrough' => true,
// Fill of the checked box. 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 dimension into an arbitrary class.
$sizes = [
'sm' => 'size-4',
'md' => 'size-4',
'lg' => 'size-5',
];
// 'default' is shadcn's canonical size token; alias it to md (additive).
$size = $size === 'default' ? 'md' : (isset($sizes[$size]) ? $size : 'md');
// Checked colour per tone: the native accent, the animated box and its tick.
$tones = [
'neutral' => ['accent' => 'accent-foreground', 'box' => 'peer-checked:border-foreground peer-checked:bg-foreground', 'tick' => 'text-background'],
'primary' => ['accent' => 'accent-primary', 'box' => 'peer-checked:border-primary peer-checked:bg-primary', 'tick' => 'text-primary-foreground'],
];
$tone = isset($tones[$tone]) ? $tone : 'neutral';
// Laravel-native wiring (mirrors input). @aware leaves $name undefined with no
// wrapping field, so normalise it; an explicit name attribute also counts.
$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;
// Reference the field's error region only when inheriting AND there is an error.
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
// A named control needs a stable id so the field's <label for> targets it.
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
$isRequired = (bool) ($required ?? false) || $attributes->has('required');
@endphp
@if ($animated)
{{-- Animated variant: a real (sr-only) checkbox drives a custom box + a
spring-drawn SVG tick. The native input stays the source of truth for
state, keyboard and form submission; the label wraps it so the whole
row is clickable. Box fill/border/focus-ring are peer-* token utilities;
the tick draw + label strike are handled in ui.css (a sibling-descendant
the peer combinator can't reach). Colour is token-only throughout.
The box's invisible ::before widens the pointer target to 24x24. --}}
<label
data-slot="checkbox-field"
@class([
'inline-flex items-center gap-2 select-none',
'cursor-pointer' => ! $disabled,
'cursor-not-allowed opacity-50' => $disabled,
])
>
<input
type="checkbox"
data-slot="checkbox"
data-variant="animated"
data-size="{{ $size }}"
data-tone="{{ $tone }}"
@if ($lineThrough) data-line-through="true" @endif
class="peer sr-only"
@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
@if ($indeterminate)
aria-checked="mixed"
x-data="{}"
x-init="$el.indeterminate = true"
@endif
{{ $attributes->except(['class', 'name', 'id', 'required', 'aria-required']) }}
/>
{{-- The input is sr-only, so consumer `class` lands on the visible box
(matching the default branch, where `class` reaches the control). --}}
<span
aria-hidden="true"
{{ $attributes->only('class')->class([
'relative grid shrink-0 place-items-center rounded-[0.3rem] border-2 bg-background transition-colors before:absolute before:-inset-1',
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
$sizes[$size],
'border-input '.$tones[$tone]['box'] => ! $isInvalid,
'border-destructive peer-checked:border-destructive peer-checked:bg-destructive' => $isInvalid,
]) }}
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
@class(['ui-checkbox-anim-check size-full p-0.5', $isInvalid ? 'text-destructive-foreground' : $tones[$tone]['tick']])
>
<path d="M3.5 8.5l3 3 6-7" pathlength="1" />
</svg>
</span>
@if (filled($label))
<span class="ui-checkbox-anim-label text-sm leading-none text-foreground transition-colors">{{ __($label) }}</span>
@endif
</label>
@else
{{-- Default: the native control tinted with the tone's accent (no extra
border — matching the radio so a checked box doesn't show a doubled
outer ring). An invisible ::before reaches 4px past the box on every
side, so the pointer target is at least 24x24 (WCAG 2.5.8) while the
drawn box and the layout stay 16px. --}}
@php
// `relative` anchors the hit area unless the caller positions the box itself.
$positioned = preg_match('/(^|\s)(absolute|fixed|sticky)(\s|$)/', (string) $attributes->get('class')) === 1;
$base = $sizes[$size].($positioned ? '' : ' relative').' shrink-0 cursor-pointer transition-colors before:absolute before:-inset-1 '
.'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background '
.'disabled:cursor-not-allowed disabled:opacity-50';
$state = $isInvalid ? 'accent-destructive' : $tones[$tone]['accent'];
$classes = trim($base.' '.$state);
@endphp
<input
type="checkbox"
data-slot="checkbox"
data-size="{{ $size }}"
data-tone="{{ $tone }}"
@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
@if ($indeterminate)
aria-checked="mixed"
x-data="{}"
x-init="$el.indeterminate = true"
@endif
{{ $attributes->except(['name', 'id', 'required', 'aria-required'])->merge(['class' => $classes]) }}
/>
@endif
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.7 - 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