Skip to main content
Copy for LLM

Checkbox

A form control for binary choices or multi-select lists. Supports checked, unchecked, and indeterminate states with full keyboard and screen-reader support.

Basic Usage

Simple checkbox

States

Every state a prop can set
  • Unchecked
  • Checked
  • Indeterminate
  • Disabled
  • Disabled and checked

Every state here is one a prop sets. Hover, focus and active are deliberately absent: no stylesheet can put an element into a pseudo-class, so a picture of one would show a state drawn by the page rather than by the component.

Sizes

Three sizes scale the box and its checkmark together — sm, md (default), and lg. They line up with the matching size on Radio and Toggle so a form mixing the controls stays visually consistent.

Checkbox sizes

Card Variant

variant="card" turns the whole control into a bordered, fully-clickable card that highlights when checked — the canonical "selectable option" pattern for feature toggles and multi-select grids. The card reacts to its own input via CSS :has(), so it needs no JavaScript.

Selectable cards

With Hint

Hint text

Monthly product updates, unsubscribe anytime.

With Error

Error state

This field is required.

Errors from Laravel's validation bag are shown automatically when name matches a field, also for a nested name such as items[0][qty], whose error Laravel stores as items.0.qty, and for a list such as tags[]. In a Livewire form, the error disappears once the field passes validation.

Indeterminate State

The indeterminate state is useful for "parent" checkboxes when only some of their children are checked — e.g. a "Select all" control. The demo below uses Alpine.js to keep the parent in sync with its children:

Select-all parent with indeterminate state

The indeterminate state is set via the DOM property input.indeterminate = true — there is no HTML attribute for it, so the server cannot render it and something has to apply it after every render.

WireKit emits data-wk-indeterminate="true|false" on the input in both states and watches it. That is deliberately not x-init, which runs once: the third state almost always arrives after the first render — a Livewire round trip morphs the element, the attribute text changes, Alpine does not re-initialize, and the property keeps its old value. The box then reads as "none selected" while something is selected, which is exactly the state it exists to show. The attribute is also written when the prop is false, so returning to a determinate state has something to undo it.

It is not x-effect either. An effect over a value the server rendered re-runs exactly as often as x-init does — once — so x-effect is right where the value is reactive Alpine state (as in data-table) and wrong here.

x-bind:indeterminate (above) is the other half and stays as it is: it reacts to live Alpine state, which is a different source from the server-rendered prop.

Verifying it from a developer app

Grep the installed package for data-wk-indeterminate in resources/views/components/checkbox.blade.php, not for x-effect — the mechanism deliberately avoids the latter, so its absence says nothing about the capability.

Important: When binding an array-style name (e.g. name="stack[]") with Alpine's x-model, drop the value="..." attribute and bind to a boolean state (one key per checkbox), not to an array. With value present, Alpine expects x-model to target an array and will silently mis-render the checked state. WireKit auto-generates unique DOM ids for array-style names so <label for> keeps working, but the x-model / value semantics remain your responsibility.

With Rich Label (Slot)

For labels that contain links or formatting — common in GDPR/privacy consent checkboxes — use the default slot instead of the label prop:

Checkbox with links inside the label slot

The slot content is rendered as raw HTML, so links, <strong>, <em>, and other inline elements work. When both label and slot content are provided, the slot takes precedence.

A common pattern for newsletter sign-ups: link the privacy policy and terms to static pages that open in a WireKit modal instead of navigating away.

Consent checkbox with in-page modal links

Discard your changes?

Discard your changes?

The <button type="button"> prevents the checkbox from toggling when clicking a link (the .stop modifier makes sure the click doesn't bubble up to the label and retrigger a toggle). The modal opens by dispatching wirekit-modal-show directly on window — this guarantees the listener is hit regardless of DOM position.

Why the <div x-data> wrapper? Alpine directives (x-on, $dispatch, $refs, …) only work inside an x-data scope. Without it, x-on:click on the inline buttons would be silently ignored. The x-data can be empty — its job here is simply to activate the Alpine component tree.

Livewire Integration

Bind a checkbox to a boolean Livewire property via wire:model.live for single-toggle controls. For multi-select groups, share a name="…[]" across the group with a unique value per checkbox and bind every input to the same array property — the framework collects checked values into the array.

<x-wirekit::checkbox name="terms" label="Accept terms" wire:model.live="acceptedTerms" />

{{-- Array binding (multi-select) --}}
<x-wirekit::checkbox name="tags[]" value="php" label="PHP" wire:model.live="selectedTags" />
<x-wirekit::checkbox name="tags[]" value="js" label="JavaScript" wire:model.live="selectedTags" />

A single checkbox needs a bool property, or a model attribute with a boolean cast. A number, such as a tinyint column without the cast, is taken for the box's value rather than its state, so the box shows unchecked whatever the property holds and does not follow a value the server sets. In debug mode the component logs a warning for a binding to a number.

Optimistic UI

Pass the name of the Livewire method this component should call and the change appears immediately, then confirms or undoes itself when the server answers:

<x-wirekit::checkbox
    name="notify"
    label="Notify me"
    :checked="$notify"
    optimistic="toggleNotify"
/>

Load wirekit-optimistic.js alongside whichever bundle you already use — it is a separate file so applications that do not use it pay nothing for it:

@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></script>

Try it

The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.

Optimistic checkbox — accepted, refused, and a slow answer

The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo methods so the page can show a real round trip. The block under it is what you write.

The browser has already ticked the box by the time any handler runs, so what this adds is the confirmation, the undo, and the announcement. Pass the current value with :checked so the component knows where it started; optimistic replaces wire:model.live rather than joining it.

What a screen reader hears is the same for every component that supports this, and it is written out in full — one hedged announcement at the change, silence on confirmation, one more only if the server refuses, and focus that never moves — in Optimistic UI — the announcement contract. That page is also where the boundary is stated: this covers mutations, not sorting, filtering or pagination.

Props

Prop Type Default Description
optimistic string|null null Livewire method to call, showing the new state before the server confirms it. See Optimistic UI.
optimisticArgs array [] Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to.
label string|null null Text next to the checkbox
hideLabel bool false Render the label sr-only (kept as the control's accessible name) — for a checkbox in a table column whose header already names it. Same prop as input / select / textarea / combobox
hint string|null null Helper text below
help string|null null An explanation of the field, shown in a tooltip from a question mark beside the label and read as the field's description. Needs a label to stand beside. See Help beside the label
error string|null null Error message (also triggers aria-invalid)
groupError bool true Take the surrounding field.set's group error, or decline it with :group-error="false". A control that belongs to the group but neither causes the rejection nor can resolve it is then neither marked aria-invalid by the group nor described by its message; its own error, its own hint and the set's hint are unaffected
indeterminate bool false Show dash instead of check (requires Alpine)
size string 'md' Box size — sm, md, or lg
variant string 'default' default (inline) or card (the whole bordered card is the selectable target)
announceError bool|null null Render the error message in an aria-live="polite" region so a validation error that appears after render reaches a screen reader. Precedence: this prop, then the parent <x-wirekit::form :announce-errors>, then config('wirekit.a11y.announce_error'). Set false when the page runs its own live region.
scope string|null null Scoped personalization name

Slots

Slot Description
Default Rich HTML label content (links, formatting). Takes precedence over the label prop.
labelAfter Markup after the label and its help question mark, in the same row and outside both, such as a badge that says a setting is not decided yet. See A Mark After the Label.

All other attributes pass through to <input>, including name, id, checked, disabled, value, wire:model.

Accessibility

  • Uses a native <input type="checkbox"> — all keyboard and screen-reader behavior is inherited from the browser.
  • The visual box and checkmark are purely decorative (aria-hidden="true").
  • Focus ring appears on the box when the hidden input receives keyboard focus.
  • aria-invalid and aria-describedby are set automatically when error or hint are present.
  • Inside <x-wirekit::field.set>, the checkbox also points at the group's hint, or at the group's error while it carries aria-invalid. A message for the group renders once, above the checkboxes.
  • A label in another language than the page, such as a language's own name, takes lang. It goes on the label, which holds the words and the checkbox, so a screen reader says them in that language's voice:
<x-wirekit::checkbox name="languages[]" value="ja" label="日本語" lang="ja" />

Keyboard Interaction

Key Action
Tab Move focus to the checkbox
Space Toggle the checked state

Pitfalls

  • Don't omit <x-wirekit::label> association. Labeling via the component's label prop (or the <x-slot:label> slot) is what makes the entire label clickable AND announces the binding to screen readers.
  • Don't wrap a checkbox in a <button>. The component already renders an interactive <input type="checkbox"> — wrapping it in another interactive element confuses focus order.

Design Tokens

Token Purpose
--color-wk-accent Fill color when checked
--color-wk-accent-fg Checkmark + dash color
--color-wk-bg-input Unchecked box background
--color-wk-border-strong / --color-wk-border-error Box border
--radius-wk-sm Box corner radius
--color-wk-ring Focus ring

Personalization blocks

Three visual zones are resolvable through WireKit::personalize() / WireKit::scope(), and a config override under components.checkbox.classes.{block} reaches the same keys:

Block key Applies to
checkbox.base The visible box — fill, border, focus ring, hit area
checkbox.label The wrapping <label> — its display mode, gap and padding (both variants)
checkbox.text The visible label text, whether it comes from the label prop or the slot

Making the checkbox a full-width row

The default label is inline-flex, so it is exactly as wide as its content. That is right for a consent line and wrong for an option list, where a code or a count belongs at the row's right edge: with an inline-flex wrapper there is no edge to push it to, and flex-1 inside the slot has nothing to stretch against.

Passing class="flex w-full" on the tag does not change this — the attribute bag is spread onto the <input>, not onto the <label>. Swap the display mode through the label block instead, and register it as a scope so only the call sites that want a row get one:

// app/Providers/AppServiceProvider.php
use Pushery\WireKit\WireKit;

public function boot(): void
{
    WireKit::scope('option-row', [
        'checkbox' => [
            'classes' => [
                // A closure receives the shipped classes, so this stays a delta rather
                // than a copy you now own — the rest of the block keeps updating with you.
                'label' => fn (string $vendor): string => str_replace('inline-flex', 'flex w-full', $vendor),

                // `flex` here too, not only `flex-1`: the span stretches to the row's width
                // as a flex ITEM, but its own children stay inline until it is a flex
                // CONTAINER, and `ms-auto` on the trailing element has nothing to act on.
                'text' => 'flex min-w-0 flex-1 items-center gap-[var(--gap-wk-sm)] text-[length:var(--text-wk-md)] text-[color:var(--color-wk-text)] select-none',
            ],
        ],
    ]);
}
<x-wirekit::checkbox name="attributes[]" value="{{ $attribute->id }}" scope="option-row">
    <span class="truncate">{{ $attribute->name }}</span>
    <span class="ms-auto shrink-0 text-[length:var(--text-wk-xs)] text-[color:var(--color-wk-text-muted)]">{{ $attribute->code }}</span>
</x-wirekit::checkbox>

The text block is also what decouples the label's text size from the size prop, which sizes the box only.

A plain-string override replaces the block's whole class string, so prefer the closure form above when you only want to change one thing. And a utility that appears only inside a personalize() / scope() call is outside Tailwind's content scan — it needs an @source line pointing at that file, or the class resolves to no CSS and the change silently does nothing. See Class overrides.

hideLabel is unaffected either way: sr-only is appended outside the block, so a replaced text block cannot accidentally unhide a label a call site asked to hide.

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store