Checkbox
A control for toggling an option on or off.
Codeapp/demos/public/checkbox.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { Checkbox } from '@stemsui/registry/ui/checkbox.tsx'
export const CheckboxDemo = clientEntry(import.meta.url, function CheckboxDemo(handle: Handle) {
let terms = false
let submitted: string | null = null
return () => (
<div class="flex w-full max-w-sm flex-col gap-6">
<div class="flex items-center gap-3">
<Checkbox
id="terms"
checked={terms}
onCheckedChange={(checked) => {
terms = checked
void handle.update()
}}
/>
<label for="terms" class="text-sm leading-none font-medium select-none">
Accept terms and conditions
</label>
<span class="ml-auto text-sm text-muted-foreground" data-testid="terms-state">
{terms ? 'Accepted' : 'Not accepted'}
</span>
</div>
<label class="flex items-start gap-3 select-none">
<Checkbox defaultChecked />
<span class="grid gap-1.5">
<span class="text-sm leading-none font-medium">Wrapped in a label</span>
<span class="text-sm text-muted-foreground">
Clicking anywhere on the label toggles the checkbox.
</span>
</span>
</label>
<label class="flex items-center gap-3 text-sm leading-none font-medium select-none">
<Checkbox indeterminate />
Indeterminate
</label>
<label class="flex items-center gap-3 text-sm leading-none font-medium text-muted-foreground select-none">
<Checkbox disabled />
Disabled
</label>
<form
class="flex flex-col gap-3 rounded-lg border p-4"
mix={on('submit', (event) => {
event.preventDefault()
let entries = [...new FormData(event.currentTarget).entries()]
submitted = entries.map(([key, value]) => `${key}=${String(value)}`).join('&')
void handle.update()
})}
>
<label class="flex items-center gap-3 text-sm font-medium select-none">
<Checkbox name="newsletter" value="weekly" defaultChecked />
Weekly newsletter
</label>
<label class="flex items-center gap-3 text-sm font-medium select-none">
<Checkbox name="marketing" value="yes" uncheckedValue="no" />
Marketing emails
</label>
<label class="flex items-center gap-3 text-sm font-medium select-none">
<Checkbox name="agree" required />I agree (required)
</label>
<button
type="submit"
class="inline-flex h-8 items-center justify-center self-start rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Save
</button>
<p class="text-sm text-muted-foreground" data-testid="checkbox-form-result">
Submitted: <code class="font-mono text-foreground">{submitted ?? '(not yet)'}</code>
</p>
</form>
</div>
)
})
Installation
npx shadcn add @stemsui/checkboxRequires the one-time setup. Behavior: stems-ui/checkbox.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/checkbox
checkbox wires a tickable control (role="checkbox") with an indicator, a mixed state, and a
hidden checkbox for native form participation. App code owns the markup and visual styles.
Base UI equivalent: Checkbox.
Usage
import * as checkbox from 'stems-ui/checkbox'
function TermsCheckbox() {
return () => (
<label class="flex items-center gap-2">
<checkbox.Context name="terms" required>
<span class="checkbox" mix={checkbox.root()}>
<span class="checkbox-indicator" mix={checkbox.indicator()}>
✓
</span>
</span>
</checkbox.Context>
Accept terms
</label>
)
}
Sibling label with a <span> root: pass id to the Context. It goes on the hidden input, like
Base UI. With a <button> root, put id on the button instead.
<label for="terms">Accept terms</label>
<checkbox.Context id="terms">
<span mix={checkbox.root()}>
<span mix={checkbox.indicator()}>✓</span>
</span>
</checkbox.Context>
Inside <checkboxGroup.Context> the checked state comes from the group (see
stems-ui/checkbox-group), including the parent checkbox.
Listen for changes on the checkbox or any ancestor:
<div mix={checkbox.onCheckboxChange((event) => console.log(event.value, event.checked))}>
{/* checkboxes */}
</div>
stems-ui/checkbox
Context: owns the checked state and renders the hidden<input type="checkbox">right after its children. Props (same as Base UI'sCheckbox.Root):checked: controlled checked state. Controlled when notundefined. Ignored inside a group.defaultChecked: initial uncontrolled state. Defaults tofalse. Ignored inside a group.onCheckedChange(checked, event): called with the next state and the DOM event that caused it.indeterminate: show the mixed state (aria-checked="mixed"). Controlled by the app; clicking still ticks or unticks the underlying value. Aparentcheckbox computes it from its group.disabled: ignore interaction, and disable the hidden input (so it is not submitted).readOnly: show the state but don't let the user change it.required: the checkbox must be ticked for the form to be valid (native constraint validation).name: form field name.value: submitted while ticked (defaults to"on", like a native checkbox). Inside a group it identifies the checkbox; it falls back toname.uncheckedValue: submitted while unticked (through an extra<input type="hidden">). By default nothing is submitted.form: id of the form that owns the hidden inputs.id: id of the hidden input, for<label for>with a non-button root.inputRef(input, signal): receives the hidden checkbox.parent: inside a checkbox group withallValues, tick or untick every child and show the mixed state when some are ticked.handle.context.get(checkbox.Context)returns{ checked, indeterminate, disabled, readOnly, required }.
root(): wires the control. Setsrole="checkbox",aria-checked("true","false", or"mixed"),aria-readonly,aria-required, the state data attributes, and click and Space activation. On a<button>host it setstype="button"and nativedisabled; on other hoststabIndex=0(removed when disabled) andaria-disabled. A parent checkbox getsaria-controlslisting its children; children in a group withallValuesget a generatedid(unless they have one).indicator({ keepMounted? }): setsaria-hidden="true"and the same state data attributes. It ishiddenwhile the checkbox is neither ticked nor mixed, after its exit animation (Base UI'sdata-starting-style/data-ending-stylelifecycle).keepMounted: truekeeps it visible so CSS can style the unticked state. (A mixin can't remove its element, so "not mounted" meanshidden.)CheckboxChangeEvent: bubblingstems:checkbox-changeevent withcheckedandvalue(thevalueornameoption), dispatched from the root.onCheckboxChange(handler, capture?): typed listener mixin forCheckboxChangeEvent.
Behavior notes
- Host: use a
<span>(the Base UI default) or a<button>.<input>hosts are not supported, because the indicator needs a child element and the Context already renders the input. - Forms: activating the root clicks the hidden checkbox, so native
inputandchangeevents fire on it andFormDatasees it. Controlled checkboxes whose owner ignores the change snap back. Focus on the hidden input (for example from browser validation) moves to the root. The nativeindeterminateproperty mirrors the mixed state. - Labels: keep
<checkbox.Context>inside a wrapping<label>, so the hidden input is the label's control. Clicking the label toggles the checkbox once. A<span>root isn't labelable, so after mount it getsaria-labelledbypointing at the hidden input's label (anidis generated for the label if it has none), unless the host already hasaria-labeloraria-labelledby. - Keyboard: Space toggles (WAI-ARIA APG). Enter is cancelled, so it doesn't activate a
<button>root or submit a form. - Explicit host props win (
attrs()semantics). - Base UI's change
reasonis always'none', so Stems doesn't pass one.
| Key | Action |
|---|---|
Space | Toggles the checkbox (on keyup for non-button hosts) |
Enter | Nothing (default prevented) |
Field integration
- Standalone: inside
<field.Context>the checkbox is the field's control. The hidden checkbox gets the field's control id (<label mix={field.label()}>targets it; clicking the label ticks the checkbox) andname;requiredand the field'svalidate(called with the checked state) run on it. The root getsaria-labelledby(the field label),aria-describedby,aria-invalid, the field state attributes, and is focused when the field blocks a form submission. - In a checkbox group: the group is the field's control (see
checkbox-group). Each checkbox gets the field's state attributes andaria-invalid; wrap it in<field.ItemContext>so afield.label()/field.description()inside the item label and describe it (the hidden input gets the item's control id). Its hidden input submits itsvalueunder the field'sname. disabledalso comes from the group, afield.ItemContext, the field, or an enclosingfieldset.Context.
Styling hooks
Root and indicator get the same attributes. Boolean attributes are present ("") or absent.
| Attribute | Present when |
|---|---|
data-checked | Ticked (and not mixed) |
data-unchecked | Unticked (and not mixed) |
data-indeterminate | Mixed state |
data-disabled | disabled is set (or the group is disabled) |
data-readonly | readOnly is set |
data-required | required is set |
data-starting-style / data-ending-style | Indicator only: while it animates in / out |
data-valid | In a field: validated and valid |
data-invalid | In a field: invalid |
data-dirty | In a field: changed from the initial value |
data-touched | In a field: the control lost focus once |
data-filled | In a field: ticked |
data-focused | In a field: the control has focus |
In a group, the field attributes describe the whole group's field (data-filled: any checkbox
is ticked). The root also gets aria-invalid="true" while the field is invalid.