Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Checkbox

A checkbox turns one option on or off. A native <input type="checkbox"> sits underneath, and the browser keeps doing the toggling: a click on the input or its label, or Space, toggles it, and the form submits it. UniAria labels it and links its description and error message (as Field does), and adds the indeterminate and read-only states, controlled use, and state attributes on every part for your CSS.

It follows the WAI-ARIA Checkbox pattern, with the native input’s role and state in place of role="checkbox" and aria-checked.

Select all
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The checkboxes' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
.list,
.rows {
  display: grid;
  gap: 0.25rem;
  justify-items: start;
}
.rows {
  padding-inline-start: 1.625rem;
}
</style><div class="list">
  <uni-checkbox id="all">
    <label data-uni="checkbox">
      <input data-element="Input" type="checkbox" />
      <span data-element="Indicator"></span>
      <span data-element="Label">All toppings</span>
    </label>
  </uni-checkbox>
  <div class="rows">
    <uni-checkbox class="topping">
      <label data-uni="checkbox">
        <input data-element="Input" type="checkbox" checked />
        <span data-element="Indicator"></span>
        <span data-element="Label">Basil</span>
      </label>
    </uni-checkbox>
    <uni-checkbox class="topping">
      <label data-uni="checkbox">
        <input data-element="Input" type="checkbox" />
        <span data-element="Indicator"></span>
        <span data-element="Label">Olives</span>
      </label>
    </uni-checkbox>
    <uni-checkbox class="topping">
      <label data-uni="checkbox">
        <input data-element="Input" type="checkbox" />
        <span data-element="Indicator"></span>
        <span data-element="Label">Chili</span>
      </label>
    </uni-checkbox>
  </div>
</div>
<output id="log">1 of 3 selected</output>
<div class="list">
  <uni-checkbox invalid required>
    <div data-uni="checkbox">
      <label data-element="Wrapper">
        <input data-element="Input" type="checkbox" />
        <span data-element="Indicator"></span>
        <span data-element="Label">Accept the terms</span>
      </label>
      <p data-element="Description">We keep them short.</p>
      <p data-element="Error">You must accept the terms.</p>
    </div>
  </uni-checkbox>
  <uni-checkbox disabled>
    <label data-uni="checkbox">
      <input data-element="Input" type="checkbox" checked />
      <span data-element="Indicator"></span>
      <span data-element="Label">Delivery included</span>
    </label>
  </uni-checkbox>
  <uni-checkbox readonly>
    <label data-uni="checkbox">
      <input data-element="Input" type="checkbox" checked />
      <span data-element="Indicator"></span>
      <span data-element="Label">Recurring order</span>
    </label>
  </uni-checkbox>
</div>
<script src="/demo/checkbox.js"></script><script>// "All toppings" is computed from the rows: selected when every row is,
// indeterminate when some are. Every checkbox here is controlled.
const all = document.getElementById('all');
const rows = [...document.querySelectorAll('.topping')];
const log = document.getElementById('log');

function update() {
  const count = rows.filter((row) => row.isSelected).length;
  all.isSelected = count === rows.length;
  all.isIndeterminate = count > 0 && count < rows.length;
  log.textContent = `${count} of ${rows.length} selected`;
}

for (const row of rows) {
  // Start from what the markup checked.
  row.isSelected = row.instance.getState().isSelected;
  row.addEventListener('selectedchange', (event) => {
    row.isSelected = event.detail;
    update();
  });
}
all.addEventListener('selectedchange', (event) => {
  for (const row of rows) row.isSelected = event.detail;
  update();
});
update();
</script>

Usage

React

import { checkbox } from '@uniaria/core/checkbox';
import { useBehavior } from '@uniaria/react';

export function TermsCheckbox(props: {
  isInvalid: boolean;
  onAcceptedChange(isAccepted: boolean): void;
}) {
  const terms = useBehavior(checkbox, {
    labelElementType: 'span',
    isRequired: true,
    isInvalid: props.isInvalid,
    onSelectedChange: props.onAcceptedChange,
  });
  return (
    <div {...terms.parts.root}>
      <label {...terms.parts.wrapper}>
        <input {...terms.parts.input} name="terms" />
        <span {...terms.parts.indicator} />
        <span {...terms.parts.label}>Accept the terms</span>
      </label>
      <p {...terms.parts.error}>You must accept the terms to continue.</p>
    </div>
  );
}

Vue

<script setup lang="ts">
import { checkbox } from '@uniaria/core/checkbox';
import { useBehavior } from '@uniaria/vue';

const props = defineProps<{ isInvalid: boolean }>();
const emit = defineEmits<{ acceptedChange: [isAccepted: boolean] }>();

const terms = useBehavior(checkbox, () => ({
  labelElementType: 'span',
  isRequired: true,
  isInvalid: props.isInvalid,
  onSelectedChange: (isSelected) => emit('acceptedChange', isSelected),
}));
</script>

<template>
  <div v-bind="terms.parts.root">
    <label v-bind="terms.parts.wrapper">
      <input v-bind="terms.parts.input" name="terms" />
      <span v-bind="terms.parts.indicator" />
      <span v-bind="terms.parts.label">Accept the terms</span>
    </label>
    <p v-bind="terms.parts.error">You must accept the terms to continue.</p>
  </div>
</template>

HTML

<uni-checkbox id="terms" required>
  <div data-uni="checkbox">
    <label data-element="Wrapper">
      <input data-element="Input" type="checkbox" name="terms" />
      <span data-element="Indicator"></span>
      <span data-element="Label">Accept the terms</span>
    </label>
    <p data-element="Error" hidden>You must accept the terms to continue.</p>
  </div>
</uni-checkbox>

<script type="module">
  import '@uniaria/elements/checkbox';

  const terms = document.querySelector('#terms');
  terms.addEventListener('selectedchange', (event) => {
    terms.isInvalid = !event.detail;
  });
</script>

The input’s checked in the markup, or a click before the script loaded, becomes the element’s starting state.

The error part shows only while the checkbox is invalid and not disabled; UniAria toggles its hidden attribute and announces it when it appears. See Field for labelling, descriptions and errors.

Two layouts

A click toggles the checkbox natively in both, with no script:

  • A label around the input, the indicator and a span label part, so a click anywhere in it toggles. Labels can’t nest, so in React and Vue pass labelElementType: 'span'; the custom element reads the tag itself. The label is the wrapper part, and the root goes around it, the description and the error, which stay outside the label so a click on them doesn’t toggle. The usage above has this layout. A checkbox with neither can make the label its root, with no wrapper, as the demo does.
  • A div root with a label label part, whose for UniAria points at the input. Only the input and the label toggle; an indicator outside the label doesn’t.

React

const newsletter = useBehavior(checkbox, { defaultSelected: true });
return (
  <div {...newsletter.parts.root}>
    <input {...newsletter.parts.input} name="newsletter" />
    <label {...newsletter.parts.label}>Subscribe to the newsletter</label>
    <p {...newsletter.parts.description}>One email a month.</p>
  </div>
);

Vue

<script setup lang="ts">
const newsletter = useBehavior(checkbox, { defaultSelected: true });
</script>

<template>
  <div v-bind="newsletter.parts.root">
    <input v-bind="newsletter.parts.input" name="newsletter" />
    <label v-bind="newsletter.parts.label">Subscribe to the newsletter</label>
    <p v-bind="newsletter.parts.description">One email a month.</p>
  </div>
</template>

HTML

<uni-checkbox>
  <div data-uni="checkbox">
    <input data-element="Input" type="checkbox" name="newsletter" checked />
    <label data-element="Label">Subscribe to the newsletter</label>
    <p data-element="Description">One email a month.</p>
  </div>
</uni-checkbox>

To draw your own box, hide the native input from sight but not from the page: lay it over the label with opacity: 0, as the demo does, or clip it. Never display: none, visibility: hidden or hidden, which take it out of the tab order and the accessibility tree.

Indeterminate

isIndeterminate shows a dash: it sets the input’s indeterminate property and data-indeterminate on every part. It is presentational and independent of isSelected. A click selects an indeterminate checkbox that isn’t selected, as the browser does, and the dash stays as long as the prop is true: you clear it. A “select all” checkbox computes both from its rows.

React

const all = useBehavior(checkbox, {
  labelElementType: 'span',
  isSelected: selected.length === toppings.length,
  isIndeterminate: selected.length > 0 && selected.length < toppings.length,
  onSelectedChange: (isSelected) => setSelected(isSelected ? toppings : []),
});

Vue

<script setup lang="ts">
const toppings = ['cheese', 'olives', 'basil'];
const selected = ref<string[]>([]);

const all = useBehavior(checkbox, () => ({
  labelElementType: 'span',
  isSelected: selected.value.length === toppings.length,
  isIndeterminate:
    selected.value.length > 0 && selected.value.length < toppings.length,
  onSelectedChange: (isSelected) =>
    (selected.value = isSelected ? [...toppings] : []),
}));
</script>

<template>
  <label v-bind="all.parts.root">
    <input v-bind="all.parts.input" />
    <span v-bind="all.parts.indicator" />
    <span v-bind="all.parts.label">All toppings</span>
  </label>
</template>

HTML

function update() {
  const count = rows.filter((row) => row.isSelected).length;
  all.isSelected = count === rows.length;
  all.isIndeterminate = count > 0 && count < rows.length;
}
all.addEventListener('selectedchange', (event) => {
  for (const row of rows) row.isSelected = event.detail;
  update();
});

The indeterminate attribute on <uni-checkbox> sets it from markup. The demo above has the whole script.

For a list of checkboxes with one value, use a CheckboxGroup.

Controlled

Pass isSelected and the checkbox shows that value. A click still toggles the input natively and fires selectedchange with the new state; if you don’t pass it back by the end of that event (a React state update or a Vue ref set in the callback counts), the checkbox goes back to isSelected. On the element, set the isSelected property, and set it again in a selectedchange listener to accept a change. See State.

In a form

  • Submission. The input is a native checkbox: while checked, the form submits its name with its value (on when it has none). You write both; the value prop writes the input’s value for you, which a CheckboxGroup needs.
  • Reset. A form reset returns an uncontrolled checkbox to defaultSelected, even one set after it rendered; a controlled one gets a selectedchange asking for defaultSelected, or without one the state it mounted with. It leaves indeterminate alone, as the browser does.
  • Disabled. A disabled checkbox is never submitted, and neither is one disabled with isFocusableWhenDisabled. A read-only one is submitted.
  • Validation. In a Form, the form holds the selected state under its name and checks it on each change: isRequired fails while it is off. isRequired writes aria-required, not the native required, so the browser shows no message of its own. Without a form, you decide when it is invalid and set isInvalid.

See Forms.

Anatomy

Part Element Notes
root Any; usually div Optional. The outermost element, around the wrapper, the description and the error. No role. Without a wrapper, a label root makes its whole area toggle the checkbox.
wrapper Any; a label makes its whole area toggle the checkbox Optional. The area around the input, the indicator and the label. No role.
input input Required. UniAria writes type="checkbox", its id (unless it has one) and ARIA attributes, disabled, and the checked and indeterminate properties. You own name, value (unless the value prop is set), form, autofocus, tabindex and the checked attribute.
indicator Any; usually span Optional. The box you draw in place of the input, with the check or the dash. Gets aria-hidden="true".
label label by default, any element through labelElementType Names the checkbox. A span inside a label wrapper or root.
description Any Optional. Outside a label wrapper or root.
error Any Optional. Outside a label wrapper or root. Hidden unless invalid.
errorText any, inside error Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself.

A part keeps an id you give it, and everything that refers to the part follows it; UniAria writes an id only on a part that has none (see Ids).

You own every part’s content. UniAria fills none of them.

Props

Prop Attribute Type Default Description
isSelected — (property) boolean — The state, controlled. Pair it with onSelectedChange.
defaultSelected default-selected boolean false The starting state, uncontrolled. The element adopts the input’s checked.
isIndeterminate indeterminate boolean false Shows the dash. See Indeterminate.
isDisabled disabled boolean false Native disabled: not focusable, never toggles, not submitted. Hides the error.
isFocusableWhenDisabled focusable-when-disabled boolean false While disabled, aria-disabled instead of disabled: focusable, so a tooltip can explain it, but never toggles or submits.
isReadOnly readonly boolean false aria-readonly="true", and every toggle is cancelled. Focusable and submitted.
isRequired required boolean false aria-required="true", not the native required.
isInvalid invalid boolean — aria-invalid="true", and shows and announces the error. Unset in a Form, the form’s errors decide it.
value value string — The checkbox’s key in a CheckboxGroup’s value, written to the input’s value. Required in a group.
owner group (an id) Instance — The CheckboxGroup of a checkbox that isn’t inside it.
inputId — string generated The input’s id while the input has none of its own.
labelElementType — (read from the tag) string 'label' The label part’s tag. 'span' inside a label wrapper or root; only a label gets for.
labelledBy labelled-by string — More ids for aria-labelledby, after the label part’s.
describedBy described-by string — More ids for aria-describedby, after the description’s and the error’s.

In a Form, Checkbox also takes name, form, validate, validationTrigger, validationDelay, validationMessages, dependsOn, isEqual and shouldPreserve: see Field props for the form.

Events

Event Callback prop Detail When
selectedchange onSelectedChange boolean The user toggled the checkbox (right before the native change), or a form reset asks for its default.
focuschange onFocusChange boolean The input gained or lost focus.

selectedchange never fires for a value you set. A checkbox in a group reports through the group’s valuechange instead. The custom element dispatches each event on itself, bubbling, with the value in detail. In React, UniAria’s part props include the input’s onInput, onClick, onFocus and onBlur: use the events above rather than replacing them. In Vue, a listener of your own runs beside UniAria’s.

State

Field Type Meaning
isSelected boolean The current state: the controlled value, the uncontrolled one, or the group’s.
isIndeterminate boolean The isIndeterminate prop.
isFocused boolean The input has focus.
isFocusVisible boolean The input has focus from a keyboard or assistive technology.
isHovered boolean A mouse or pen is over the wrapper (without one, the root) or the input. Never while disabled.
isDisabled boolean Disabled, by the prop or the group.
isReadOnly boolean Read-only, by the prop or the group.
isRequired boolean Required, by the prop or the group.
isInvalid boolean Invalid, by the prop or the group.
isErrorShown boolean The error part is shown: invalid and not disabled.

To render something from state in React or Vue, select it, and the component renders again only when that value changes (Vue gives a ref):

const isAccepted = useBehaviorState(terms, (state) => state.isSelected);

On the custom element, read element.instance.getState(). The isSelected property holds only the value you set, not the current state. See State.

Styling

Every part carries the state attributes, so the box, the label and the messages can be styled without reading state.

Attribute When
data-uni="checkbox" On the root, always.
data-element="<Part>" On every other part, always.
data-selected The checkbox is selected.
data-indeterminate isIndeterminate is true.
data-hovered A mouse or pen is over the wrapper (without one, the root) or the input.
data-focused The input has focus.
data-focus-visible The input has focus from a keyboard or a screen reader.
data-disabled The checkbox is disabled.
data-readonly The checkbox is read-only.
data-invalid The checkbox is invalid.
data-required The checkbox is required.

A checkbox can be both selected and indeterminate: put the dash’s rules after the check’s so the dash wins. State attributes are present or absent, never "false". There is no pressed state; use :active. UniAria writes no styles.

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout. It lays the input over the wrapper, or over a label root, invisible and clickable, and draws the check and the dash on the indicator’s ::before.

Keyboard

Key Behavior
Space Toggles, natively. Nothing when read-only or focusable while disabled.
Enter Nothing on the checkbox: it submits the form, natively.
Tab Moves focus, natively. A disabled checkbox is skipped, unless isFocusableWhenDisabled is set.

Checkbox handles no keys itself.

Accessibility

  • Native state. Screen readers read the input’s own role and checked and indeterminate state (“mixed”), and announce a toggle themselves, so UniAria never writes aria-checked and announces nothing on a toggle.
  • Spoken output. “checkbox, Accept terms, not checked”, then “checked”. The description follows the name; “read only”, “required”, “invalid” and “disabled” follow the state. An invalid checkbox reads its error as its description.
  • Labelling. Give every checkbox a label part, or an aria-label on the input. A link inside the label wrapper or root (“I accept the terms”) keeps its own click.
  • Focus. A press on the input, or on a label that activates it, focuses the input in every browser, Safari included, which doesn’t on its own. Focus from a pointer isn’t visible: data-focus-visible stays off, so draw the ring on it. Keys and scripts move no focus, and neither does a press on a link inside the label.
  • Disabled with a reason. Natively disabled checkboxes can’t be reached by keyboard. To explain one with a Tooltip, set isFocusableWhenDisabled and link the tooltip through the tooltip prop (React and Vue) or attribute (an id).

Compared with other libraries

  • A native input. The APG examples build role="checkbox" with aria-checked; UniAria keeps the native checkbox, as React Aria does.
  • selectedchange is React Aria’s onChange, with the same isSelected, defaultSelected and isIndeterminate.
  • Read-only cancels the click, as in Zag; React Aria lets the toggle happen and reverts it. No input or change event fires.
  • A press focuses the input, on the input or its label, as React Aria and Zag do on a label press, so focus is the same in every browser.
  • The checked attribute stays the default. Zag reflects the state into it, which leaves a native form reset nothing to restore.
  • No validation. React Aria can validate natively; UniAria shows isInvalid and leaves the decision to your form.