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.
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>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.
A click toggles the checkbox natively in both, with no script:
- A
labelaround the input, the indicator and aspanlabel part, so a click anywhere in it toggles. Labels can’t nest, so in React and Vue passlabelElementType: 'span'; the custom element reads the tag itself. Thelabelis thewrapperpart, and the root goes around it, the description and the error, which stay outside thelabelso a click on them doesn’t toggle. The usage above has this layout. A checkbox with neither can make thelabelits root, with no wrapper, as the demo does. - A
divroot with alabellabel part, whoseforUniAria 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.
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.
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.
- Submission. The input is a native checkbox: while checked, the form
submits its
namewith itsvalue(onwhen it has none). You write both; thevalueprop writes the input’svaluefor 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 aselectedchangeasking fordefaultSelected, or without one the state it mounted with. It leavesindeterminatealone, 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
nameand checks it on each change:isRequiredfails while it is off.isRequiredwritesaria-required, not the nativerequired, so the browser shows no message of its own. Without a form, you decide when it is invalid and setisInvalid.
See Forms.
| 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.
| 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.
| 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.
| 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.
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.
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.
| 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.
- Native state. Screen readers read the input’s own role and
checkedandindeterminatestate (“mixed”), and announce a toggle themselves, so UniAria never writesaria-checkedand 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-labelon the input. A link inside thelabelwrapper or root (“I accept the terms”) keeps its own click. - Focus. A press on the input, or on a
labelthat 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-visiblestays 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
isFocusableWhenDisabledand link the tooltip through thetooltipprop (React and Vue) or attribute (an id).
- A native input. The APG examples build
role="checkbox"witharia-checked; UniAria keeps the native checkbox, as React Aria does. selectedchangeis React Aria’sonChange, with the sameisSelected,defaultSelectedandisIndeterminate.- Read-only cancels the click, as in Zag; React Aria lets the toggle
happen and reverts it. No
inputorchangeevent 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
checkedattribute 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
isInvalidand leaves the decision to your form.