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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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

Accessibility requirements by control typeCheckbox, switch, radio group, select, text input and a list of per-site rows compared on the element to use, how it is labelled, and the most common defect.ControlUseLabelled byCommon defectOn/off settinginput type=checkbox<label for>div with a click handlerSwitch stylingcheckbox + role=switch<label for>State not announcedOne of severalradio in fieldset<legend>No group nameChoice from a listselect<label for>Custom dropdown, no keysFree textinput + aria-describedby<label for>Error only in colourPer-site rowslist + named buttonsaria-label per rowTen identical "Remove"
Native elements satisfy most of the middle column for free — the defects come from replacing them.

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.

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.

An auto-saved change, as a screen-reader user hears itThe user toggles a switch, the page writes to storage, the status region's text changes to Saved and is announced, then clears so the next save is announced too.UserSwitchchrome.storagerole=statusSpace"on" announcedstorage.sync.setresolvedtextContent = "Saved"announcedcleared af…
Without the status region, the change is saved and the user is never told.

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.

Accessibility defects found per settings page, by build approachAverage number of accessibility defects found in an audit for settings pages built with native controls, styled native controls, and custom div-based controls with ARIA.Native, unstyled1 defects per…Native, styled2 defects per…usually a missing focus ringCustom controls with ARIA9 defects per…
Styling native controls costs nothing in defects; replacing them is where most problems come from.

Cross-browser variation

  • Chrome / Edge: role="switch" on a checkbox is announced as a switch by NVDA, JAWS and VoiceOver. appearance: none styling 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

  1. Tab through the whole page. Every control must show a visible focus ring, and the order must follow the visual layout.
  2. With a screen reader, toggle a switch and confirm both the new state and “Saved” are announced.
  3. Enter an invalid value, move focus away and back, and confirm the error is read with the field.
  4. 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.

Other UI/UX Patterns & Interactive Components Resources