Skip to content

Checkbox

A box the user ticks. Reach for it when an option is independently on or off, and for the one in a set of many that can each be chosen: a list of permissions, a row of filters, the terms nobody reads. When the choice is one of several, that is a radio group; when the option takes effect the moment it changes rather than when a form is submitted, a switch says so more clearly.

The whole row is the control, so the label is part of the hit target and part of the accessible name rather than a second thing to click.

tsx
/**
 * Four checkboxes, and each one a different answer to "who owns this
 * value".
 *
 * `Accept the terms` and `Send me product updates` are controlled: the
 * application holds both. The second one's handler refuses to write
 * while the terms are unticked, so clicking it does nothing until they
 * are, which is the whole of what controlled means. A `Checkbox` never
 * ticks itself; it draws the value it was handed.
 *
 * `Remember this device` is the other form. It was given
 * `defaultChecked` and no `checked`, so it keeps its own value in one
 * cell and the application never hears about it.
 *
 * `Import from the old account` is disabled, so it takes neither a
 * click nor a key.
 *
 * Nothing here names a colour. The tick, the border, the hover, the
 * press and the red of the invalid box all come from the control
 * tokens in whatever theme the tree inherits.
 */
export function Preferences(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const terms = internalState(false);
  const updates = internalState(false);

  const status = combineLatest([terms, updates]).pipe(
    map(([accepted, wants]) =>
      !accepted ? 'Accept the terms to choose the rest' : wants ? 'We will email you' : 'We will not email you'
    )
  );

  return (
    <column gap={10} padding={20} width={percent(100)} height={percent(100)} y="center">
      <Checkbox
        label="Accept the terms"
        checked={terms}
        onChange={next => (terms.value = next)}
        required
        invalid={terms.pipe(map(accepted => !accepted))}
      />
      <Checkbox
        label="Send me product updates"
        checked={updates}
        onChange={next => {
          if (terms.value) {
            updates.value = next;
          }
        }}
      />
      <Checkbox label="Remember this device" defaultChecked />
      <Checkbox label="Import from the old account" checked={false} disabled />
      <text text={status} fontSize={12} color="textMuted" />
    </column>
  );
}

Tab into the group and try it. The terms box is unticked and required, so it is drawn with the danger border and announces invalid until it is ticked. The updates box is controlled by a handler that refuses to write while the terms are unticked, so clicking it does nothing at all, which is the point. "Remember this device" is uncontrolled: it keeps its own value, and the application never hears about it.

Props

PropTypeDefaultWhat it does
checkedbooleannoneThe value, when the application owns it. Supplying this makes the box controlled.
defaultCheckedbooleannoneThe starting value, when the box owns it. Supplying this makes the box self-managing.
onChange(checked: boolean) => voidnoneCalled with the value the box would take, on a click and on a bound key.
labelstring''Drawn beside the box, and used as the accessible name.
disabledbooleanfalseRefuses clicks and keys, greys the label, and marks the subtree unavailable.
invalidbooleanfalseDraws the box's border in danger and adds the invalid state.
requiredbooleanfalseAdds the required state. It does not enforce anything: validity is the application's.
refUiNodeRefnoneReceives the node that is the control, for focusing it or anchoring something to it.

Neither checked nor defaultChecked has a default in the sense of a value the component substitutes: supplying neither leaves the box self-managing and starting unticked. Supplying both throws.

Every prop takes a plain value or an Observable of one, and the layout props on the library page apply here too.

Controlled and uncontrolled

tsx
// Controlled: the application owns the value, and the box shows it.
<Checkbox label="Wrap lines" checked={wrap} onChange={next => (wrap.value = next)} />

// Uncontrolled: the box owns the value, and reports changes if asked.
<Checkbox label="Wrap lines" defaultChecked onChange={next => save(next)} />

The controlled form is the one to reach for, because it is the only one where the value in the box and the value in your state cannot disagree. It also lets the application decline: onChange fires, and if nothing writes back then the box does not move. The example above does exactly that, and the spec beside it proves the box stays unticked through a click the handler declined.

The uncontrolled form is for a control whose value nothing else needs: one internalState cell inside the component instead of one in your store. It still calls onChange, so it is not a black box, but the value lives and dies with the control.

Keyboard

The row is one tab stop. Both bindings toggle, and both consume the event, so nothing above the checkbox sees the key.

KeyWhat it does
SpaceToggles the box
EnterToggles the box
TabNot bound: focus moves on as it normally would

Enter toggles as well as Space because a checkbox here is a row rather than a native input, and a reader who has just arrived on it with the keyboard should not have to know which of the two this framework chose. A disabled box takes neither.

Semantics

WhatValue
Rolecheckbox, on the row that takes focus
Namelabel
ValueNo valueNow: a checkbox is on or off, and says so with checked
Stateschecked while ticked, invalid while invalid, required while required
Disableddisabled is carried on the record, and inherited by everything under it

The states arrive as they change rather than being read once, so a box the application ticks from elsewhere updates what an assistive technology sees without anything re-rendering.

The label text inside the row has no record of its own. ARIA calls the children of a checkbox presentational, and a screen reader that read both the row and the text inside it would say everything twice.

Next

Switch is the same control with a different role, and RadioGroup is what to use when the options are exclusive.