---
title: 'Selection Controls'
source: 'https://academia.sh/en/courses/accessible-patterns/selection-controls'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# Selection Controls

The separate problems solved by the checkbox, radio button, and switch; naming the group, declaring the mixed state, and testing two different keyboard contracts.

In the text field, the name's source was the field's own label. The "Notification
method" section in the borrowing form, by contrast, consists of three separate
controls — "Email," "SMS," "No notification" — and the question the user is answering is
not written in any of the three names. The question belongs to the group itself.

This lesson specifies three selection controls: the checkbox, the radio button, and the
switch. All three look similar, but the problems they solve and their keyboard contracts
differ. Setting up the distinction incorrectly produces an interface that keeps working
with the keyboard but behaves differently than expected.

## Three Controls, Three Separate Problems

**The checkbox** is an independent on/off choice. In the catalog's filter panel, "Only
items on the shelf," "Only e-books," and "Periodicals" are independent of one another;
none of them turns another off. Zero of them can be selected, or all of them can.

**The radio button** is a mutually exclusive choice, and it always sits inside a group.
The borrow duration is 14, 21, or 28 days; when one is selected, the other turns off. A
single radio button on its own is meaningless — once the user selects it, they cannot
back out of the selection.

**The switch** is an on/off **setting**, and it takes effect the instant it is changed.
The "Send return reminders" setting in the user's account is like this: it is changed,
it is saved, and it does not wait for a submit button on the page.

The distinction follows from this: a switch is not used inside a form that will be
confirmed with a submit button; a checkbox is used instead. The promise a switch makes is
"this took effect now"; a switch waiting for the form to be submitted breaks that
promise.

## The Native Element First

The checkbox and the radio button have native counterparts: the `checkbox` and `radio`
types of the `input` element. Both bring role, checked state, keyboard contract, and
inclusion in the submission list together. What ties radio buttons into a group is their
`name` attribute being the same; the group arises from this name, not from adjacency in
the markup.

The switch has **no native counterpart**. The way it is met is a button carrying a
`role="switch"` declaration; its state is written with `aria-checked`. Writing
`role="switch"` onto a checkbox is also defined, and in that case the element's native
checked state passes into the tree as the switch state.

## The Group's Name

When the group has no name, the user hears the three options but not the question. The
native counterpart is the `fieldset` and `legend` elements: the `fieldset` element
produces a group role in the tree, and the `legend` element's text becomes the group's
name.

```html
<fieldset>
  <legend>Notification method</legend>
  <p><label><input type="radio" name="notification" value="email" checked> Email</label></p>
  <p><label><input type="radio" name="notification" value="sms"> SMS</label></p>
  <p><label><input type="radio" name="notification" value="none"> No notification</label></p>
</fieldset>

<fieldset>
  <legend>Filters</legend>
  <p><label><input type="checkbox" name="shelf" value="on-shelf"> Only items on the shelf</label></p>
  <p><label><input type="checkbox" name="type" value="ebook"> Only e-books</label></p>
</fieldset>
```

Two details are binding. First, each control's own label is built with the `label`
element; the label wrapping the control (an implicit binding) gives the same result
without writing a `for`/`id` pair, and it also adds the label text to the clickable area.
Second, the radio buttons' `name` value is the same; when it is written differently,
three separate single-option groups form, and the user can select all three at once.

When the group element cannot be used, the counterpart is a `role="group"` declaration,
or `role="radiogroup"` for radio buttons; the group's name is tied to a heading element
with `aria-labelledby`. This route is chosen only when the `fieldset` element's layout
constraints cause a problem.

## Role, Name, and State

All three controls' names come from their own labels; the group's name does not replace
the control's name, it becomes its context.

The state declaration is checkedness. In native elements it passes to the tree from the
`checked` property; in a hand-built control, it is written with the `aria-checked` value
and updated on every change.

The checkbox has a third state: the **mixed state** (`mixed`). The "Select all" checkbox
in the filter panel is in this state while some subset of the child checkboxes is
selected. In the native element, the mixed state is set not with an attribute but with
the `indeterminate` property, and it is declared to the tree as mixed. The mixed state is
not a third option: when the user clicks it, the control either selects all or clears
all, and it does not return to mixed.

The switch has no mixed state. Whether the switch's state is **also** conveyed in text is
a design decision: "On"/"Off" text is not part of the control's name, because the name
does not change; the state is declared only through `aria-checked`, and the text becomes
an additional indicator.

## Two Keyboard Contracts

Radio group:

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the group; the entire group is a **single** tab stop, and focus lands on the selected button. |
| Arrow keys | Move focus and selection together; wrap to the start at the ends. |
| `Space` | Selects the focused button. |
| `Tab` (while inside the group) | Exits the entire group, moves to the next stop. |

Checkbox and switch:

| Key | Behavior |
|---|---|
| `Tab` | Each control is its own tab stop. |
| `Space` | Toggles the state. |
| Arrow keys | Have no effect; focus and selection do not change. |

The difference between the two contracts is that the radio group uses the **roving
tabindex** arrangement: only one item in the group is in the tab order, and the rest are
reached with the arrow keys. A group of fifteen options thus occupies one stop, not
fifteen. Checkboxes cannot use this arrangement because they are independent; the user
has to reach each one separately.

The test below builds both contracts as a state machine and checks them with key
sequences.

```js
// selection.test.mjs — the radio group's and checkbox group's keyboard contract
// Run: node --test selection.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const ITEMS = ["Email", "SMS", "No notification"];
const FORWARD = new Set(["ArrowDown", "ArrowRight"]);
const BACK = new Set(["ArrowUp", "ArrowLeft"]);

// focus: null means focus is outside the group
const radio = (selected = null) => ({ kind: "radio", items: ITEMS, selected, focus: null });
const checkbox = () => ({ kind: "checkbox", items: ITEMS, selected: new Set(), focus: null });

// Number of tab stops the group occupies
function tabStops(d) {
  return d.kind === "radio" ? 1 : d.items.length;
}

function key(d, t) {
  const n = d.items.length;
  if (d.kind === "radio") {
    if (d.focus === null) return t === "Tab" ? { ...d, focus: d.selected ?? 0 } : d;
    if (t === "Tab") return { ...d, focus: null };
    if (FORWARD.has(t)) { const i = (d.focus + 1) % n; return { ...d, focus: i, selected: i }; }
    if (BACK.has(t)) { const i = (d.focus + n - 1) % n; return { ...d, focus: i, selected: i }; }
    if (t === "Space") return { ...d, selected: d.focus };
    return d;
  }
  if (d.focus === null) return t === "Tab" ? { ...d, focus: 0 } : d;
  if (t === "Tab") return d.focus === n - 1 ? { ...d, focus: null } : { ...d, focus: d.focus + 1 };
  if (t === "Space") {
    const s = new Set(d.selected);
    if (s.has(d.focus)) s.delete(d.focus); else s.add(d.focus);
    return { ...d, selected: s };
  }
  return d;   // arrow keys do not affect the checkbox group
}

const apply = (d, keys) => keys.reduce(key, d);

test("the radio group is a single tab stop, checkboxes occupy one stop per item", () => {
  assert.equal(tabStops(radio()), 1);
  assert.equal(tabStops(checkbox()), 3);
});

test("focus lands on the selected item when entering the group", () => {
  assert.equal(apply(radio(2), ["Tab"]).focus, 2);
});

test("with no selection, focus lands on the first item on entry and no selection is made", () => {
  const d = apply(radio(), ["Tab"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, null);
});

test("arrow keys move focus and selection together, wrapping to the start at the end", () => {
  const d = apply(radio(0), ["Tab", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, 0);
  const g = apply(radio(0), ["Tab", "ArrowUp"]);
  assert.equal(g.selected, 2);
});

test("Tab exits the entire group in a single press", () => {
  const d = apply(radio(1), ["Tab", "ArrowDown", "Tab"]);
  assert.equal(d.focus, null);
  assert.equal(d.selected, 2);
});

test("arrow keys do nothing on a checkbox", () => {
  const d = apply(checkbox(), ["Tab", "ArrowDown", "ArrowRight"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected.size, 0);
});

test("checkboxes toggle independently of one another", () => {
  const d = apply(checkbox(), ["Tab", "Space", "Tab", "Tab", "Space"]);
  assert.deepEqual([...d.selected].sort(), [0, 2]);
  const k = apply(d, ["Space"]);
  assert.deepEqual([...k.selected], [0]);
});

test("Space selects the focused item in a radio group", () => {
  const d = apply(radio(0), ["Tab", "Space"]);
  assert.equal(d.selected, 0);
});
```

```
✔ the radio group is a single tab stop, checkboxes occupy one stop per item (0.301542ms)
✔ focus lands on the selected item when entering the group (0.070375ms)
✔ with no selection, focus lands on the first item on entry and no selection is made (0.042ms)
✔ arrow keys move focus and selection together, wrapping to the start at the end (0.048625ms)
✔ Tab exits the entire group in a single press (0.305208ms)
✔ arrow keys do nothing on a checkbox (0.063833ms)
✔ checkboxes toggle independently of one another (0.412541ms)
✔ Space selects the focused item in a radio group (0.055291ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.528375
```

The durations in parentheses and the total duration vary from run to run; the test's
result does not change.

The third test catches the contract's most commonly skipped point: when the group is
entered while no option is checked, focus lands on the first item, but **no selection is
made**. The rule that focus drags the selection along applies only to the arrow keys;
the moment of entry is exempt from it. When this distinction is missed in hand-built
groups, the user ends up selecting a borrow duration they did not want, just by pressing
Tab.

The fifth test is the direct consequence of the roving tabindex: even though two steps
were taken with the arrow keys inside the group, `Tab` exits the group with a single
press.

## Measurable Constraints

**Target size.** A checkbox's visual box is typically between 16 and 20 pixels and does
not satisfy the 2.5.8 criterion on its own. The label text being clickable together with
the control — the `label` element provides this on its own — enlarges the target up to
the text's width. The vertical spacing between boxes in a filter list also has to be
opened enough to satisfy 2.5.8's spacing exception.

**1.4.11 Non-text Contrast.** The border of the box and the button has to separate from
the page surface by at least 3:1. The same measure applies to the checked indicator: the
mark has to be distinguishable from the fill inside the box.

**1.4.1 Use of Color.** The checked state cannot be conveyed by fill color alone; a
checkmark, a dot, or the switch's shifted position is the second channel.

**3.2.2 On Input.** Changing a checkbox must not submit the form on its own or change the
page. Refreshing the results table when a box is checked in the filter panel is
acceptable behavior, because the context does not change; the page address changing and
focus being lost is not acceptable.

## Common Mistake

**Using a checkbox for a mutually exclusive choice.** Catching it is behavioral: if the
first box turns off on its own when the user checks the second one, the wrong component
has been chosen. For the user, this means the reason for the turn-off is announced
nowhere.

**Leaving the group nameless.** Catching it is done with a presence rule: every group
carrying radio buttons has to have a name. A heading text sitting above the group on
screen is not a name unless a binding is established.

**Placing a switch in a form that waits for a submit button.** Catching it means asking
whether a saving step is required after the switch is changed. If it is, the component is
converted to a checkbox.

**Declaring the mixed state as `false`.** If the "Select all" checkbox appears off while
some subset of the child checkboxes is selected, the user is given wrong information.
Catching it means comparing the count of the child checkboxes against the parent
checkbox's declared state.

## Summary

- The checkbox is an independent on/off choice, the radio button is a mutually exclusive
  choice, and the switch is a setting that takes effect the instant it is changed; a
  switch is not used in a form that waits to be submitted.
- The checkbox and radio button have native counterparts; the switch does not and is
  built with `role="switch"`. The radio group is formed by the `name` attribute being
  identical.
- The group's name is given with `fieldset` and `legend`; the group name does not replace
  the control's name, it becomes its context.
- The radio group uses roving tabindex: the group occupies a single stop, and the arrow
  keys move focus and selection together; checkboxes, by contrast, are separate stops and
  do not respond to the arrow keys.
- On first entry into the group, if no option is selected, focus lands on the first item
  but no selection is made; this is the rule most often skipped in hand-built groups.
- The mixed state is not a third option, it is a summary of the child selections; when
  the user clicks, either all are selected or all are cleared.

## Next Step

A radio group works when the options are enumerable and few in number. The "Subject"
field in the catalog's filter panel, though, carries two hundred fifty values, and not
all of them can be shown on screen. This requires handing the selection work off to a
closable list: the user opens the list, navigates it, narrows it by typing, and selects a
value. The next lesson specifies this component as two separate patterns — the native
selection element and the hand-built combobox — and shows why the second is the pattern
that requires the most declarations.
