Checkbox Group
Shared state for a set of checkboxes.
Selected: gala
Codeapp/demos/public/checkbox-group.tsx
import { clientEntry, type Handle } from 'remix/component'
import * as checkboxGroup from 'stems-ui/checkbox-group'
import { Checkbox } from '@stemsui/registry/ui/checkbox.tsx'
const apples = [
{ value: 'fuji', label: 'Fuji' },
{ value: 'gala', label: 'Gala' },
{ value: 'granny-smith', label: 'Granny Smith' },
]
const permissions = [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' },
{ value: 'admin', label: 'Admin (locked)', disabled: true },
]
export const CheckboxGroupDemo = clientEntry(
import.meta.url,
function CheckboxGroupDemo(handle: Handle) {
let selected: string[] = ['gala']
return () => (
<div class="flex w-full max-w-sm flex-col gap-8">
<checkboxGroup.Context
value={selected}
allValues={apples.map((apple) => apple.value)}
onValueChange={(value) => {
selected = value
void handle.update()
}}
>
<div
class="flex flex-col gap-3"
aria-labelledby="apples-label"
mix={checkboxGroup.root()}
>
<label
id="apples-label"
class="flex items-center gap-3 text-sm font-medium select-none"
>
<Checkbox parent />
All apples
</label>
{apples.map((apple) => (
<label class="ml-7 flex items-center gap-3 text-sm select-none">
<Checkbox name="apple" value={apple.value} />
{apple.label}
</label>
))}
</div>
</checkboxGroup.Context>
<p class="-mt-4 text-sm text-muted-foreground" data-testid="checkbox-group-value">
Selected:{' '}
<code class="font-mono text-foreground">
{selected.length ? selected.join(', ') : '(none)'}
</code>
</p>
<checkboxGroup.Context defaultValue={['read', 'admin']}>
<div
class="flex flex-col gap-3"
aria-labelledby="permissions-label"
mix={checkboxGroup.root()}
>
<div id="permissions-label" class="text-sm font-medium">
Permissions (uncontrolled)
</div>
{permissions.map((permission) => (
<label class="flex items-center gap-3 text-sm select-none has-data-disabled:text-muted-foreground">
<Checkbox
name="permission"
value={permission.value}
disabled={permission.disabled}
/>
{permission.label}
</label>
))}
</div>
</checkboxGroup.Context>
</div>
)
},
)
Installation
npx shadcn add @stemsui/checkboxRequires the one-time setup. Behavior: stems-ui/checkbox-group, stems-ui/checkbox.
API reference
The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.
stems-ui/checkbox-group
checkboxGroup shares one string[] value between a set of checkboxes from
stems-ui/checkbox, and drives an optional parent ("select all")
checkbox. App code owns the markup and visual styles.
Base UI equivalent: Checkbox Group.
Usage
import * as checkbox from 'stems-ui/checkbox'
import * as checkboxGroup from 'stems-ui/checkbox-group'
function Apples() {
return () => (
<checkboxGroup.Context defaultValue={['fuji']}>
<div aria-labelledby="apples" mix={checkboxGroup.root()}>
<div id="apples">Apples</div>
{['fuji', 'gala'].map((value) => (
<label>
<checkbox.Context name="apple" value={value}>
<span mix={checkbox.root()}>
<span mix={checkbox.indicator()}>✓</span>
</span>
</checkbox.Context>
{value}
</label>
))}
</div>
</checkboxGroup.Context>
)
}
Parent checkbox: list every child value in allValues and add a checkbox with parent.
<checkboxGroup.Context value={value} onValueChange={setValue} allValues={['fuji', 'gala']}>
<div aria-labelledby="apples" mix={checkboxGroup.root()}>
<label id="apples">
<checkbox.Context parent>
<span mix={checkbox.root()} />
</checkbox.Context>
All apples
</label>
{/* child checkboxes with value="fuji" / value="gala" */}
</div>
</checkboxGroup.Context>
stems-ui/checkbox-group
Context: owns the group value. Props (same as Base UI'sCheckboxGroup):value: ticked checkbox values. Controlled when notundefined.defaultValue: initially ticked values (uncontrolled). Defaults to[].onValueChange(value, event): called with the next value and the DOM event that caused it.allValues: every child value. Needed for aparentcheckbox.disabled: disable every checkbox in the group.handle.context.get(checkboxGroup.Context)returns{ value, allValues, disabled }.
root(): setsrole="group"anddata-disabled. Name it witharia-labeloraria-labelledby.CheckboxGroupChangeEvent: bubblingstems:checkbox-group-changeevent with the nextvalue, dispatched from the checkbox that changed.onCheckboxGroupChange(handler, capture?): typed listener mixin for the event.
Behavior notes
- A checkbox inside the group is identified by its
valueoption (falling back toname). Its ownchecked/defaultCheckedare ignored. A checkbox with neithervaluenornamestays standalone. - Each ticked checkbox submits its own hidden input (
name=value), so give them a sharednamefor form submission. - Parent checkbox: ticked when every value in
allValuesis ticked, mixed when some are. Like Base UI, clicking it cycles mixed → all → none → back to the selection the user last made with the child checkboxes (or toggles all/none when that selection was empty or complete). Disabled children keep their state. Values outsideallValuesare preserved. The parent getsaria-controlslisting the children's ids; children without anidget a generated one. - A checkbox reads the nearest group. Base UI's "nested parent checkbox" example (a parent checkbox that is also a child of an outer group) is not covered or tested yet.
- Base UI's change
reasonis always'none', so Stems doesn't pass one.
Field integration
Inside <field.Context> the group is the field's control, like Base UI's CheckboxGroup in a
Field.Root:
<field.Context name="protocols" validate={(value) => (value.length ? null : 'Pick one')}>
<div id="protocols-label" mix={field.label()}>
Protocols
</div>
<checkboxGroup.Context>
<div mix={checkboxGroup.root()}>
{['http', 'ssh'].map((value) => (
<field.ItemContext>
<div mix={field.item()}>
<checkbox.Context value={value}>
<span mix={checkbox.root()} />
</checkbox.Context>
<label mix={field.label()}>{value}</label>
</div>
</field.ItemContext>
))}
</div>
</checkboxGroup.Context>
<div mix={field.error()}>…</div>
</field.Context>
- The field value is the array of ticked values (
validatereceives it;onFormSubmitvalues have it under the field'sname). Each ticked checkbox's hidden input submits itsvalueunder the field'sname. requiredgoes on individual checkboxes: the first unticked required checkbox is the field's validity element (valueMissing), and it is focused when the field blocks a submission (otherwise the first enabled checkbox is).- The root gets the field state attributes,
aria-labelledby(afield.label()outside any item), andaria-describedby(the field's descriptions and visible errors).role="group"has noaria-invalid, so each checkbox gets it. field.ItemContextlabels and describes one checkbox (Base UI'sField.Item).- Focus moving between the group's checkboxes doesn't blur the field:
data-touched(andvalidationMode="onBlur") waits until focus leaves the group. - The field's (or an enclosing
fieldset.Context's)disableddisables every checkbox.
Styling hooks
| Part | Attribute | Present when |
|---|---|---|
| root | data-disabled | disabled is set (or the field / fieldset is disabled) |
Checkboxes inside the group expose their usual attributes (data-checked, data-indeterminate,
...). Inside a field, the root and every checkbox also get the field state attributes
(data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused); Base
UI documents only data-disabled on the group, the others are a Stems extra.
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.