Accessible Form Controls for Extension Settings
Build extension settings that work with a keyboard and a screen reader — labelled toggles, grouped radios, custom switches done properly, inline validation and save feedback that is announced.
Table of Contents
Settings pages are forms, and forms are where accessibility defects concentrate: a toggle that is a styled <div> with no role, a group of radio buttons with no legend, an error shown in red text that a screen reader never mentions, a “Saved” message that appears and fades before anyone hears it. Extension options pages add auto-save, which removes the submit button that would otherwise have been the natural moment to report success or failure. This guide is part of options page layouts.
What each control needs
Step-by-step
1. Use real inputs, styled
1<div class="setting">
2 <input type="checkbox" id="auto-save" role="switch" aria-describedby="auto-save-help">
3 <label for="auto-save">Save pages automatically</label>
4 <p id="auto-save-help" class="help">Pages you read for more than a minute are saved to your list.</p>
5</div>
Execution context: the options page. A native checkbox is focusable, operable with Space, and announced with its state. Adding role="switch" makes screen readers say “on/off” instead of “checked/not checked”, which matches a toggle’s visual design — and costs nothing else. aria-describedby reads the help text after the label.
1input[role="switch"] {
2 appearance: none;
3 width: 2.25rem; height: 1.25rem;
4 border-radius: 999px;
5 background: #94a3b8;
6 position: relative;
7 cursor: pointer;
8}
9input[role="switch"]::after {
10 content: ""; position: absolute; top: 2px; left: 2px;
11 width: 1rem; height: 1rem; border-radius: 50%; background: #fff;
12 transition: transform 120ms;
13}
14input[role="switch"]:checked { background: #1d4ed8; }
15input[role="switch"]:checked::after { transform: translateX(1rem); }
16input[role="switch"]:focus-visible { outline: 2px solid #1d4ed8; outline-offset: 2px; }
17@media (prefers-reduced-motion: reduce) { input[role="switch"]::after { transition: none; } }
Execution context: the options stylesheet. Styling the native element rather than replacing it keeps keyboard behaviour, form semantics and the accessibility tree intact. The visible focus ring is required — outline: none without a replacement is the most common keyboard defect on settings pages.
2. Group related radios with a fieldset
1<fieldset>
2 <legend>Colour theme</legend>
3 <label><input type="radio" name="theme" value="auto"> Match system</label>
4 <label><input type="radio" name="theme" value="light"> Light</label>
5 <label><input type="radio" name="theme" value="dark"> Dark</label>
6</fieldset>
Execution context: the options page. The legend is announced when focus enters the group (“Colour theme, grouping, Match system, radio button, 1 of 3”), and arrow keys move between options natively. Without the fieldset, each option is announced in isolation and the question they answer is lost.
3. Validate inline and announce errors
1<label for="sync-hour">Daily sync time (hour, 0–23)</label>
2<input id="sync-hour" type="number" min="0" max="23" inputmode="numeric" aria-describedby="sync-hour-err">
3<p id="sync-hour-err" class="error" hidden></p>
1const field = document.querySelector("#sync-hour");
2const err = document.querySelector("#sync-hour-err");
3
4field.addEventListener("change", async () => {
5 const v = Number(field.value);
6 if (!Number.isInteger(v) || v < 0 || v > 23) {
7 err.textContent = "Enter a whole number from 0 to 23.";
8 err.hidden = false;
9 field.setAttribute("aria-invalid", "true");
10 return;
11 }
12 err.hidden = true;
13 field.removeAttribute("aria-invalid");
14 await saveSetting("syncHour", v);
15});
Execution context: the options page. aria-invalid plus aria-describedby means the error is read when focus returns to the field; the text states the fix, not just the fault. Colour may reinforce the error but never carry it alone. Form-level validation and reset are covered in validating and resetting options forms.
4. Name repeated controls individually
1function siteRow(origin) {
2 const li = document.createElement("li");
3 const name = document.createElement("span");
4 name.textContent = new URL(origin).hostname;
5 const remove = document.createElement("button");
6 remove.textContent = "Remove";
7 remove.setAttribute("aria-label", `Remove ${new URL(origin).hostname}`);
8 li.append(name, remove);
9 return li;
10}
Execution context: the options page. A list of ten “Remove” buttons is labelled and useless — the screen-reader user cannot tell which row each belongs to. Including the row’s name in the accessible label fixes it without changing the visible design.
5. Announce auto-save outcomes
With no submit button, the save happens silently. A status region makes it audible.
1<p id="save-status" role="status" class="visually-hidden"></p>
1const status = document.querySelector("#save-status");
2let clear;
3
4async function saveSetting(key, value) {
5 try {
6 await writeSetting(key, value);
7 status.textContent = "Saved";
8 } catch {
9 status.textContent = "Couldn't save — check your connection and try again";
10 }
11 clearTimeout(clear);
12 clear = setTimeout(() => (status.textContent = ""), 4000);
13}
Execution context: the options page. A persistent role="status" element announces each change of its text; clearing it after a few seconds means the next “Saved” is a change and is announced again. A visual toast can mirror it for sighted users, but the live region is the part that carries the information.
Custom controls when you truly need them
Sometimes a native element cannot express the design — a colour picker with named swatches, a segmented control, a slider with labelled stops. Building one accessibly means reproducing every behaviour the native element would have given you, and it is worth being honest about the cost before starting.
A segmented control is the most common case, and it is a radio group in disguise. Build it as one:
1<fieldset class="segmented">
2 <legend>Reading width</legend>
3 <input type="radio" name="width" id="w-narrow" value="narrow"><label for="w-narrow">Narrow</label>
4 <input type="radio" name="width" id="w-medium" value="medium"><label for="w-medium">Medium</label>
5 <input type="radio" name="width" id="w-wide" value="wide"><label for="w-wide">Wide</label>
6</fieldset>
Execution context: the options page, with the radios visually hidden and the labels styled as segments. Arrow-key navigation, focus handling and announcements all come from the native radio group — the “custom” part is only CSS.
When a genuinely custom widget is unavoidable, follow the WAI-ARIA Authoring Practices pattern for it exactly — roles, states and keyboard map — and test it with a screen reader, as described in testing extension UI with a screen reader. Partial ARIA is worse than none: a role="slider" that does not respond to arrow keys announces itself as something the user then cannot operate.
Cross-browser variation
- Chrome / Edge:
role="switch"on a checkbox is announced as a switch by NVDA, JAWS and VoiceOver.appearance: nonestyling of checkboxes and radios is fully supported. - Firefox: equivalent support; Firefox’s accessibility inspector shows the computed role and name for each control, which is the quickest way to verify the switch role took effect.
- Safari: VoiceOver announces switches correctly. Safari’s embedded options surface may render at a different width, so check that labels do not wrap under their controls.
- All three: the options page is a normal extension page, so axe-core runs against it unmodified in CI.
Verification
- Tab through the whole page. Every control must show a visible focus ring, and the order must follow the visual layout.
- With a screen reader, toggle a switch and confirm both the new state and “Saved” are announced.
- Enter an invalid value, move focus away and back, and confirm the error is read with the field.
- Run axe against the page:
1// In the options page console, with axe loaded for testing
2(await axe.run(document, { runOnly: ["wcag2a", "wcag2aa"] })).violations.map((v) => v.id);
3// []
Execution context: the options page console in a development build that includes axe-core. Any id returned names a rule and links to its documentation.
FAQ
Is role="switch" worth it over a plain checkbox?
Yes when the visual design is a toggle — the announcement then matches what sighted users see. It is optional when the design is a checkbox.
Should auto-save wait for blur on text fields?
Save text fields on change (which fires on blur or Enter), not on every keystroke — both for the storage write rate and so the “Saved” announcement does not fire mid-typing.
Do I need visible labels, or is aria-label enough?
Visible labels. aria-label alone leaves sighted users guessing and is not translated by browser page-translation tools.
How should disabled settings be presented?
Keep them visible with the reason stated next to them — “Requires sign-in”, “Set by your organisation”. A disabled control is skipped by Tab in most browsers, so the explanation must be in text that is reachable on its own, not only in a tooltip on the disabled element.
Related
- Validating and resetting options forms — form-level validation around these controls.
- Making popups and options keyboard navigable — focus order and traps.
- Search and filter in a large options page — finding a control among many.
- Options page layouts — the parent guide.