Skip to content
Brok UI

Loading…

No results

Checkbox

Open source Core

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).

Version
v1.4.7
Stability
stable
License
MIT
Related
Radio
Switch
Choicebox

Preview

Size
Disabled
previews.components.checkbox.default.blade.php Blade
<label class="flex items-center gap-2 text-sm text-foreground">
    <x-ui.checkbox id="preview-checkbox" checked />
    {{ __('Accept terms and conditions') }}
</label>
Sm Current
Md Current
Lg Current

Installation

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

  • blade 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.

checkbox.md
# 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

animated.blade.php Blade
<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>
cards.blade.php Blade
<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>
group.blade.php Blade
<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>
sizes.blade.php Blade
<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>
states.blade.php Blade
<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.blade.php Blade
{{-- 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>
tones.blade.php Blade
<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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

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

checkbox checkbox-field

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

Independent selection

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
root checkbox-field
Theming hooks
checkbox checkbox-field

Accessibility

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

livewire-form.blade.php Blade
<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.

resources/views/components/ui/checkbox.blade.php Blade
{{-- 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