---
title: Checkbox
description: Checkbox input with label, hint, and error support
visibility: guest
draft: false
---

# 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

:::preview{title="Simple checkbox"}
<x-wirekit::checkbox name="terms" label="I agree to the terms of service" />
:::

## States

:::preview{title="Unchecked, checked, indeterminate, disabled"}
<x-wirekit::stack gap="sm">
    <x-wirekit::checkbox name="c1" label="Unchecked" />
    <x-wirekit::checkbox name="c2" label="Checked" checked />
    <x-wirekit::checkbox name="c3" label="Indeterminate (partial selection)" indeterminate />
    <x-wirekit::checkbox name="c4" label="Disabled" disabled />
    <x-wirekit::checkbox name="c5" label="Disabled + checked" disabled checked />
</x-wirekit::stack>
:::

## Sizes

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

:::preview{title="Checkbox sizes"}
<x-wirekit::stack gap="sm">
    <x-wirekit::checkbox name="cs1" size="sm" label="Small" checked />
    <x-wirekit::checkbox name="cs2" size="md" label="Medium (default)" checked />
    <x-wirekit::checkbox name="cs3" size="lg" label="Large" checked />
</x-wirekit::stack>
:::

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

:::preview{title="Selectable cards"}
<x-wirekit::stack gap="sm">
    <x-wirekit::checkbox name="feat-cache" variant="card" label="Response caching — speed up repeat requests" checked />
    <x-wirekit::checkbox name="feat-minify" variant="card" label="Minify HTML — smaller payloads" />
</x-wirekit::stack>
:::

## With Hint

:::preview{title="Hint text"}
<x-wirekit::checkbox name="newsletter" label="Subscribe to newsletter" hint="Monthly product updates, unsubscribe anytime." />
:::

## With Error

:::preview{title="Error state"}
<x-wirekit::checkbox name="required" label="I must check this" error="This field is required." />
:::

Errors from Laravel's validation bag are shown automatically when `name` matches a field. 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:

:::preview{title="Select-all parent with indeterminate state"}
<div x-data="{
        items: { php: false, js: true, css: 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.checkedCount < this.total; },
        toggleAll(state) { Object.keys(this.items).forEach(k => this.items[k] = state); }
    }">
    <x-wirekit::checkbox
        id="select-all-demo"
        label="Select all items"
        x-bind:checked="allChecked"
        x-bind:indeterminate="someChecked"
        x-on:change="toggleAll($event.target.checked)"
    />
    <x-wirekit::stack gap="xs" class="mt-2 pl-6">
        <x-wirekit::checkbox id="stack-php" label="PHP" x-model="items.php" />
        <x-wirekit::checkbox id="stack-js" label="JavaScript" x-model="items.js" />
        <x-wirekit::checkbox id="stack-css" label="CSS" x-model="items.css" />
    </x-wirekit::stack>
</div>
:::

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.

::: tip Verifying it from a consuming 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:

:::preview{title="Checkbox with links inside the label slot"}
<x-wirekit::checkbox name="consent-basic">
    I accept the
    <a href="/privacy" style="text-decoration: underline; color: var(--color-wk-accent);">Privacy Policy</a>
    and
    <a href="/terms" style="text-decoration: underline; color: var(--color-wk-accent);">Terms of Service</a>.
</x-wirekit::checkbox>
:::

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.

### Open Links in a Modal

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.

:::preview{title="Consent checkbox with in-page modal links"}
<div x-data>
    <x-wirekit::checkbox name="consent-modal">
        I accept the
        <button type="button"
            style="text-decoration: underline; color: var(--color-wk-accent); background: transparent; border: none; padding: 0; cursor: pointer; font: inherit;"
            x-on:click.prevent.stop="window.dispatchEvent(new CustomEvent('wirekit-modal-show', { detail: { name: 'privacy-demo' } }))">Privacy Policy</button>
        and
        <button type="button"
            style="text-decoration: underline; color: var(--color-wk-accent); background: transparent; border: none; padding: 0; cursor: pointer; font: inherit;"
            x-on:click.prevent.stop="window.dispatchEvent(new CustomEvent('wirekit-modal-show', { detail: { name: 'terms-demo' } }))">Terms of Service</button>.
    </x-wirekit::checkbox>

    <x-wirekit::modal name="privacy-demo" size="lg">
        <x-wirekit::modal.header>Privacy Policy</x-wirekit::modal.header>
        <x-wirekit::modal.body>
            <p>Your static privacy policy content would go here — e.g. via <code>&#64;include('legal.privacy')</code>.</p>
        </x-wirekit::modal.body>
    </x-wirekit::modal>

    <x-wirekit::modal name="terms-demo" size="lg">
        <x-wirekit::modal.header>Terms of Service</x-wirekit::modal.header>
        <x-wirekit::modal.body>
            <p>Your static terms of service content would go here.</p>
        </x-wirekit::modal.body>
    </x-wirekit::modal>
</div>
:::

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.

```blade
<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" />
```

## 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:

```blade
<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:

```blade
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></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.

:::preview{title="Optimistic checkbox — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::stack gap="lg" style="max-width: 26rem;">
    <x-wirekit::checkbox name="opt-accept" label="Accepted — the confirmation is silent" :checked="false" optimistic="demoAccept" />
    <x-wirekit::checkbox name="opt-reject" label="Refused — the tick is taken back, and said out loud" :checked="false" optimistic="demoReject" />
    <x-wirekit::checkbox name="opt-slow" label="Slow answer — the dashed outline is the provisional state" :checked="false" optimistic="demoSlow" />
</x-wirekit::stack>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::checkbox
    name="notify"
    label="Notify me"
    :checked="$notify"
    optimistic="toggleNotify"
/>
:::

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](/extending/optimistic-ui). 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](#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 |
| `error` | string\|null | `null` | Error message (also triggers `aria-invalid`) |
| `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 | `true` (from `config('wirekit.a11y.announce_error')`) | Announce the error in an `aria-live="polite"` region so a validation error that appears after render reaches a screen reader. 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. |

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.

## 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 |

## Further Reading

- [MDN: `<input type="checkbox">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox)
- [WAI-ARIA Checkbox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/)
- [MDN: `aria-checked`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-checked) — when building custom checkboxes
- [MDN: `:checked` pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/:checked)
