Module: The Operability Floor
Loaded by: accessibility-architect · design-architect · code-review-architect · testing-architect · planning-architect · implementing-architect. Read it whenever a run touches an interactive surface; do not restate its content in a skill file.
Why this is separate from the craft floor: almost everything below produces zero automated violations. A focus trap with no exit, focus never returned to the trigger after a dialog closes, a route change that announces nothing — there is no violating node for a scanner to find. They are architectural, which means they are decided when the interaction is designed, not fixed from a log afterwards.
The rule that governs all of it: role, aria-*, and tabindex change what assistive technology is told, and change nothing about what the element does. A <div role="button"> announces as a button and then ignores Enter, ignores Space, cannot be focused, and cannot be disabled. No ARIA is better than bad ARIA.
1. Native First, ARIA Second
- Use the element that already means it.
<button>,<a href>,<input>,<select>,<details>,<dialog>,<table>,<label>,<fieldset>/<legend>, heading levels in order. Each arrives with role, keyboard behaviour, focusability, and a disabled state you did not have to write and cannot forget. - Only if no native element expresses it, reach for ARIA — and then you owe the full contract, not just the role.
- Never use ARIA to describe something that is not true, and never put a role on an element that contradicts it (
role="button"on an<a href>, an interactive control insidearia-hidden).
The contract a role obliges you to supply. If you write role="button" on a non-button you must also supply: tabindex="0", an Enter handler, a Space handler (including preventing page scroll), a visible focus indicator, aria-disabled plus actually blocking the action, and an accessible name. Six things the native element gave you for free. This is why the answer is almost always "use the button".
The same control both ways — the native element is the whole contract in one line:
<button type="button" aria-label="Close dialog">×</button>
<span role="button" tabindex="0" aria-label="Close dialog"
(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); closeDialog(); }">×</span>
<!-- still owed: a :focus-visible style, and an aria-disabled path that actually blocks closeDialog() -->
Accessible name, in priority order — aria-labelledby → aria-label → the element's own content → title (weak, avoid relying on it). Rules that matter in practice:
- The visible label must be part of the accessible name. A button reading "Save" with
aria-label="Submit changes"is unusable by voice control: the user says "click Save" and nothing happens. aria-labelon a non-interactive element is usually ignored. It is not a comment field.- An icon-only control needs a name, and the icon itself is
aria-hiddenso it does not double-announce. - Decorative images take
alt=""— not a missingalt, and not a filename. Informative images describe the information, not the picture. An image inside a link describes the link's destination. - Never
aria-hiddenon a focusable element. It creates a control that exists for the keyboard and not for the screen reader.
Structure is semantics too. One <h1>; heading levels descend without skipping and reflect the document, not the font size. Landmarks (<main>, <nav>, <header>, <footer>, <aside>) with labels when repeated. Lists as lists. Tables with real <th> and scope — a data table is not a grid of divs. A skip link as the first focusable element on a page with repeated navigation.
2. Focus Management — the Half No Scanner Sees
Focus order and visibility
- Tab order follows visual order. If DOM order and visual order disagree, fix the layout — never patch it with positive
tabindex. Only0and-1are legitimate. - Everything interactive is reachable by keyboard and has a visible focus indicator.
:focus-visible, neveroutline: nonewith no replacement. - Nothing is focusable that should not be — no
tabindex="0"on wrappers, no focusable hidden content behind a closed menu or an off-screen carousel slide. - Check that focus is not obscured when it lands — a sticky-header and scroll-padding problem, and the fix is usually
scroll-margin-top.
Overlays — modal dialogs, sheets, non-modal popovers
- Move focus into the overlay on open — the first interactive element, or the dialog container with
tabindex="-1"when the content should be read from the top. - Trap focus inside it while it is modal: Tab from the last element returns to the first, Shift+Tab from the first goes to the last.
- Escape closes it, and any closable overlay also has a visible close control (Escape alone is not discoverable).
- Return focus to the element that opened it on close. This is the step that is skipped most often, and losing focus to
<body>sends a keyboard user back to the top of the document with no idea where they were. - Content behind a modal is inert —
inerton the background, oraria-hiddenon the siblings. Not on the dialog's own ancestors. - Prefer the platform's native dialog where the project can use it; it supplies most of the above.
Disclosure, menus, and expanding controls
- The trigger carries
aria-expanded(true/false), and it lives on the element that is actually activated.aria-controlswhere the relationship is not obvious. - A menu that opens on hover must also open on focus, and must not close before the pointer can travel to it.
- Arrow-key navigation within a composite widget (menu, tab set, listbox, toolbar) with a single tab stop — the roving-tabindex pattern. Tab moves between widgets; arrows move within one. Getting this backwards makes a 40-item menu a 40-press obstacle.
Single-page routing — the barrier no scan reports
A client-side route change replaces the page without any of the things a real navigation does. On every route change: move focus to the new view's heading or main container (tabindex="-1"), update the document title, and announce the change via a live region for anyone who does not track focus. Without this, a screen-reader user activates a link and hears nothing at all — the most common serious barrier in single-page applications, and it produces zero automated violations.
Dynamic content — when new content appears in response to an action, either move focus to it (if the user should act on it now) or announce it (§3). Never both. Never neither.
3. Live Regions and Async Status
For anything that changes without the user's focus moving: form validation results, save confirmations, search-result counts, upload progress, toasts, connection loss.
- The container exists in the DOM before the message does. A live region injected together with its text is frequently not announced — the assistive technology never observed a change. Render the empty region up front and write text into it.
aria-live="polite"for everything that can wait for a pause;assertiveonly for errors that interrupt the user's task. An assertive region firing on every keystroke is worse than silence.- Prefer
role="status"(implicitly polite) androle="alert"(implicitly assertive) for the common cases. - One region per purpose, reused. Multiple regions firing at once produce interleaved speech.
- Do not duplicate an announcement that focus already delivers. If focus moves to the error summary, the summary does not also need to be a live region — the user hears it twice.
- Progress and loading — announce start and end, not every percentage.
aria-busyon the region being replaced.
Form errors, the pattern that works: mark the field aria-invalid="true"; associate the message with the field via aria-describedby so it is read when focus arrives; on submit-time failure, move focus to a summary listing every error with links to the fields. Errors name the problem and the recovery — the craft floor says the same thing about copy; this is where it becomes operability.
4. The Refuse List
Each line names the default, then what to do instead.
outline: nonewith no replacement indicator → a:focus-visiblering from the palette, 2px or more, offset from the element. The single most common self-inflicted barrier.- Positive
tabindex→tabindex="0"or none, and fix the DOM order instead. aria-hiddenon anything focusable, or on a page region that still holds active controls → hide the region and make its controls inert (inert, or remove them from the tab order).- A
<div>or<span>with a click handler and nothing else →<button>; if it navigates,<a href>. aria-labelthat contradicts or omits the visible label → let the visible text be the name; extend it witharia-describedbyif more is needed.- Placeholder text as the only label → a visible
<label>; the placeholder carries a format hint at most. - Blocking paste into password or one-time-code fields → allow paste; password managers and the cognitive-function-test exemption depend on it.
- Drag as the only way to do something → add a single-pointer path — buttons, a menu, a numeric input — to the same result.
- Removing a scan rule to reach green → fix the node, or record the exemption in the profile with its reason and owner.
- An accessibility overlay or widget marketed as making a site compliant → fix the source. It does not comply, it frequently makes things worse, and disabled-user organizations broadly oppose them. Say so once, plainly, if asked to add one.