Lesson 03 / 25
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.
Contents
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.
<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.
// 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 thenameattribute being identical. - The group’s name is given with
fieldsetandlegend; 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.