Stems UI
Menu

Checkbox Group

Shared state for a set of checkboxes.

Selected: gala

Permissions (uncontrolled)
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/checkbox

Requires 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's CheckboxGroup):
    • value: ticked checkbox values. Controlled when not undefined.
    • 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 a parent checkbox.
    • disabled: disable every checkbox in the group.
    • handle.context.get(checkboxGroup.Context) returns { value, allValues, disabled }.
  • root(): sets role="group" and data-disabled. Name it with aria-label or aria-labelledby.
  • CheckboxGroupChangeEvent: bubbling stems:checkbox-group-change event with the next value, 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 value option (falling back to name). Its own checked / defaultChecked are ignored. A checkbox with neither value nor name stays standalone.
  • Each ticked checkbox submits its own hidden input (name=value), so give them a shared name for form submission.
  • Parent checkbox: ticked when every value in allValues is 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 outside allValues are preserved. The parent gets aria-controls listing the children's ids; children without an id get 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 reason is 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 (validate receives it; onFormSubmit values have it under the field's name). Each ticked checkbox's hidden input submits its value under the field's name.
  • required goes 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 (a field.label() outside any item), and aria-describedby (the field's descriptions and visible errors). role="group" has no aria-invalid, so each checkbox gets it.
  • field.ItemContext labels and describes one checkbox (Base UI's Field.Item).
  • Focus moving between the group's checkboxes doesn't blur the field: data-touched (and validationMode="onBlur") waits until focus leaves the group.
  • The field's (or an enclosing fieldset.Context's) disabled disables every checkbox.

Styling hooks

PartAttributePresent when
rootdata-disableddisabled 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's Checkbox.Root):
    • checked: controlled checked state. Controlled when not undefined. Ignored inside a group.
    • defaultChecked: initial uncontrolled state. Defaults to false. 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. A parent checkbox 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 to name.
    • 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 with allValues, 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. Sets role="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 sets type="button" and native disabled; on other hosts tabIndex=0 (removed when disabled) and aria-disabled. A parent checkbox gets aria-controls listing its children; children in a group with allValues get a generated id (unless they have one).
  • indicator({ keepMounted? }): sets aria-hidden="true" and the same state data attributes. It is hidden while the checkbox is neither ticked nor mixed, after its exit animation (Base UI's data-starting-style / data-ending-style lifecycle). keepMounted: true keeps it visible so CSS can style the unticked state. (A mixin can't remove its element, so "not mounted" means hidden.)
  • CheckboxChangeEvent: bubbling stems:checkbox-change event with checked and value (the value or name option), dispatched from the root.
  • onCheckboxChange(handler, capture?): typed listener mixin for CheckboxChangeEvent.

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 input and change events fire on it and FormData sees 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 native indeterminate property 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 gets aria-labelledby pointing at the hidden input's label (an id is generated for the label if it has none), unless the host already has aria-label or aria-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 reason is always 'none', so Stems doesn't pass one.
KeyAction
SpaceToggles the checkbox (on keyup for non-button hosts)
EnterNothing (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) and name; required and the field's validate (called with the checked state) run on it. The root gets aria-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 and aria-invalid; wrap it in <field.ItemContext> so a field.label() / field.description() inside the item label and describe it (the hidden input gets the item's control id). Its hidden input submits its value under the field's name.
  • disabled also comes from the group, a field.ItemContext, the field, or an enclosing fieldset.Context.

Styling hooks

Root and indicator get the same attributes. Boolean attributes are present ("") or absent.

AttributePresent when
data-checkedTicked (and not mixed)
data-uncheckedUnticked (and not mixed)
data-indeterminateMixed state
data-disableddisabled is set (or the group is disabled)
data-readonlyreadOnly is set
data-requiredrequired is set
data-starting-style / data-ending-styleIndicator only: while it animates in / out
data-validIn a field: validated and valid
data-invalidIn a field: invalid
data-dirtyIn a field: changed from the initial value
data-touchedIn a field: the control lost focus once
data-filledIn a field: ticked
data-focusedIn 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.