Accessible Component Patterns
The lab: modal, menu, tabs, accordion and form, each written as a contract — semantics, keys, focus and announcement — because a component that does not state these has not specified its behaviour at all.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What exactly does each of the standard interactive components owe a keyboard and a screen reader?
Someone is doing an ordinary thing — confirming a deletion, choosing from a menu, switching a tab, expanding a section, submitting a form — and expects it to behave the way every other implementation of that pattern they have ever used behaved.
Each of these is a small amount of state and a conditional render. Show the panel when open is true, hide it when it is false, and wire the click handlers.
The conditional render is the easy 20% of every one of these patterns. The rest is focus, keys, state exposure and announcement, and none of it appears in a design mock or a ticket.
- The conditional render is the easy 20% of every one of these patterns. The rest is focus, keys, state exposure and announcement, and none of it appears in a design mock or a ticket.
- A modal that renders a
divleaves focus behind it: Tab walks into the page underneath, where the user operates controls they cannot see (Focus Management). - A "dropdown menu" built with
role="menu"for site navigation announces an application menu bar, and screen-reader users get menu semantics — arrow-key-only navigation, no link list — for what is actually a set of links. - A tab strip with no
role="tablist"is a row of buttons, and the relationship between a tab and its panel exists only in the CSS. - An accordion whose
aria-expandedlives on the panel instead of the trigger exposes the state on something the user never focuses. - A form that shows errors in red next to each field tells a screen-reader user nothing at all, and leaves them with a submit button that appears to do nothing (Errors People Can Actually Perceive).
What is actually happening
In the browser, not in the framework.
- Each of these patterns has an established key set and role structure documented in the ARIA Authoring Practices, and users have learned them from operating systems and from every other web application. The convention *is* the specification; inventing a variation is a usability defect even when it is technically operable.
- Three of the five have partial or complete native equivalents:
<dialog>withshowModal()for the modal,<details>/<summary>for the disclosure that underlies an accordion, and the entire<form>element with its native validation for the form. Using them removes most of the specification (Semantics Before ARIA). - The other two — menu and tabs — have no native equivalent, so they are ARIA-plus-JavaScript by necessity, which is exactly why they carry the most obligations (The Rules of ARIA).
- Every one of them is a composite: one tab stop plus arrow keys, not a tab stop per item. That single decision is what makes them feel native (Keyboard Operability).
- Every one of them changes state, and the state must be exposed where the user is focused —
aria-expandedon the trigger,aria-selectedon the tab,aria-invalidon the field.
What this makes the browser do
And which of it is avoidable.
<dialog>withshowModal()promotes the element to the top layer, applies inertness to everything else and paints a::backdrop— all inside the engine, with no z-index arithmetic and no scroll-lock hack (Positioning and Stacking Contexts).- A hand-built modal typically adds a portal, a scroll lock on
body, a focus trap with two sentinel nodes, a keydown listener and aninerttoggle: five moving parts where the native element has none. - Rendering all tab panels and hiding them with CSS keeps their DOM cost permanently; rendering only the active one costs a mount on every switch. Both are defensible, and
hiddenon the inactive panels is what keeps them out of the accessibility tree either way. - Avoidable work: re-attaching document-level key listeners on every render of an open menu, and re-running a focus trap's node query on every keystroke instead of on open (Long Tasks).
First decide what the pattern actually is
The most expensive mistake in this lesson is made before any code is written: choosing the wrong pattern. A navigation dropdown implemented as role="menu", a select-like control implemented as a menu, a modal used for something that should have been a page — each produces a component that is correctly implemented and wrong.
The distinctions below are not pedantry. Each role carries a different interaction model that users have already learned, and announcing the wrong one hands them the wrong set of expectations.
A thing opens and shows some options. Which pattern is it?
when A button reveals or hides a chunk of related content. Nothing is chosen; nothing is commanded.
cost Almost none — <details>/<summary> natively, or a button with aria-expanded and a hidden panel. This is the right answer far more often than it is chosen.
when A list of commands that act on the current context — the right-click menu, a row actions menu, an application menu bar.
cost Full menu key set including arrow navigation, typeahead, Escape and focus return. Announces as an application menu, which is wrong for a list of links.
when Choosing a value, especially from many options, especially with filtering.
cost The most complex pattern of the five, and <select> covers a surprising amount of it natively — including a full-screen platform picker on mobile.
when A set of links under a top-level nav item.
cost The lightest option: a nav with a button carrying aria-expanded and a plain list of links. Not a menu, despite the name everyone uses for it.
when The user must deal with this before continuing, and the surrounding context must be unavailable.
cost Containment, inertness, focus restoration and Escape. If the context need not be blocked, a non-modal panel or a separate route is cheaper and less disruptive (Client-Side Routing).
Modal dialog
A modal makes a claim: nothing else on this page is available right now. If that claim is only visual — an overlay that dims the background — then keyboard and screen-reader users are not in a modal at all, they are in a page with a floating panel on it.
Use <dialog> with showModal() where your support floor allows. It provides the top layer, background inertness, Escape-to-close and a ::backdrop, and it removes the four fragile things a hand-built modal has to maintain: the portal, the scroll lock, the focus trap and the inert toggle.
semantics <dialog> opened with showModal(), or role="dialog" with aria-modal="true". Named by aria-labelledby pointing at its heading; aria-describedby for the consequence text. The background is inert — natively, or via the inert attribute.
| Escape | Closes, and is equivalent to Cancel. Native <dialog> provides this; a hand-built one must, and must not close on Escape while a nested control has its own Escape behaviour. |
| Tab / Shift+Tab | Cycles within the dialog only. Reaching the last element and pressing Tab returns to the first, in both directions. |
| Enter | Submits the dialog's form, if it has one — which is why the confirming action should be a submit button and not a click handler. |
- — On open, focus moves into the dialog: to the first meaningful control, or to the dialog container itself when the content should be read from the top. For a destructive confirmation, focus the safe option — not the destructive one.
- — Focus is contained for as long as the dialog is open; the background is
inertso it cannot be reached by Tab, by click, or by the screen reader's own navigation. - — On close, focus returns to the element that opened it. If that element no longer exists — the row it lived in was just deleted — focus moves to a designated fallback such as the list container.
- — Background scrolling is prevented, and the dialog's own content scrolls if it is taller than the viewport.
- — On open: the dialog role, its accessible name from the heading, and the description — "Delete this project? dialog. This removes all 42 documents in it."
- — The result of the action, announced once after close, through the existing status region rather than a new one (Live Regions and Announcement).
- — Nothing on Tab movement inside the dialog beyond the normal focus announcements.
usually broken by Hiding the background with aria-hidden="true" instead of making it inert. The background is then invisible to the screen reader and still fully focusable, so Tab escapes the dialog into content that announces nothing at all — a strictly worse state than doing nothing (The Rules of ARIA).
1<button type="button" id="delete-trigger">Delete project</button>2 3<dialog id="confirm" aria-labelledby="confirm-title">4 <h2 id="confirm-title">Delete this project?</h2>5 <p id="confirm-desc">6 This removes all 42 documents in it. It cannot be undone.7 </p>8 <form method="dialog">9 <button value="cancel">Cancel</button>10 <button value="confirm" class="danger">Delete project</button>11 </form>12</dialog>13 14<script>15 const dialog = document.getElementById('confirm')16 const trigger = document.getElementById('delete-trigger')17 18 trigger.addEventListener('click', () => {19 // showModal — not show, and not .open = true. Only showModal20 // gives you the top layer, the inert background and Escape.21 dialog.showModal()22 })23 24 // Still yours: restore focus. The browser returns focus to the25 // previously focused element in most cases, but if the action26 // removed the trigger, that element no longer exists.27 dialog.addEventListener('close', () => {28 if (trigger.isConnected) trigger.focus()29 else document.getElementById('project-list')?.focus()30 })31</script>method="dialog" closes the dialog on submit and reports which button was used through dialog.returnValue — no click handlers, no state, and Escape produces the same close path as Cancel, which is what makes the two behave identically.
Dropdown and menu
This is the pattern most often built with the wrong role. role="menu" and role="menuitem" model an application menu of commands, as in a desktop menu bar. Screen readers announce it as such and switch to a menu navigation model where links are not links and Tab does not move between items.
The overwhelming majority of things called dropdowns on the web are not menus. A navigation dropdown is a list of links under a button. A select-like control is a listbox. A settings panel is a disclosure. Choose role="menu" only when the contents are commands that act on the current context — and then implement the full menu key set, because you have just announced that you did.
semantics A button with aria-haspopup="menu" and aria-expanded reflecting the open state, controlling a container with role="menu" whose children are role="menuitem", menuitemcheckbox or menuitemradio. The menu is named by the button via aria-labelledby.
| Enter / Space / Down Arrow | Opens the menu and focuses the first item. Up Arrow opens it and focuses the last — the shortcut people who use menus rely on. |
| Arrow Up / Down | Moves between items, wrapping at both ends. The menu is one tab stop; Tab closes it and moves on. |
| Home / End | First and last item. |
| Printable characters | Typeahead: jumps to the next item starting with that character. Essential once a menu has more than a handful of items. |
| Escape | Closes and returns focus to the button, without invoking anything. |
| Enter | Invokes the focused item and closes the menu. |
- — Focus moves into the menu on open and is contained there; the menu is a single tab stop from the outside.
- — On close — by Escape, by invoking an item, or by clicking outside — focus returns to the button.
- — Do not close on
blur. The blur fires before the click that caused it, so the item's action is cancelled intermittently; usefocusoutwith a check ofrelatedTarget, or an outside-pointer listener. - — If invoking an item removes the button (deleting the row it belongs to), focus must go to a specified fallback, not to
body.
- — On open: "menu, Rename, 1 of 4" or the equivalent — the role, the focused item and its position in the set.
- — The button's expanded state changes with
aria-expanded; no live region is involved. - — The outcome of the invoked command, once, through the page's status region.
usually broken by Using role="menu" for navigation links, which is this pattern's signature failure. The second most common: implementing the roles and never implementing typeahead or Home/End, producing a menu that announces itself as a menu and cannot be operated like one.
<button aria-haspopup="menu" aria-expanded="false">Products</button>
<ul role="menu">
<li role="menuitem"><a href="/analytics">Analytics</a></li>
<li role="menuitem"><a href="/billing">Billing</a></li>
</ul>
<!-- The links are inside menuitems, so they are announced as
menu items and not as links; the screen reader's link list
no longer contains them; Tab no longer moves between them;
and the full menu key set is now owed and not implemented. --><button aria-expanded="false" aria-controls="products-menu">
Products
</button>
<ul id="products-menu" hidden>
<li><a href="/analytics">Analytics</a></li>
<li><a href="/billing">Billing</a></li>
</ul>
<!-- Announced as "Products, button, collapsed", then a list of
two links. Enter or Space toggles. Escape closes and returns
focus to the button. Nothing else is owed. -->The role determines the interaction model the user is promised. Links announced as menu items lose the link semantics screen-reader users navigate by, in exchange for a menu key set that this component does not implement. The disclosure is less markup, states something true, and works.
Tabs
Tabs have no native element, so every implementation is ARIA plus JavaScript, and the contract is entirely yours. The structure is three roles that reference each other: a tablist containing tabs, each pointing at a tabpanel that points back.
The design decision that matters is the activation model. Automatic activation selects the panel as the user arrows onto each tab — good for cheap, already-rendered content. Manual activation moves focus with the arrows and selects only on Enter or Space — necessary when the panel fetches data, because otherwise arrowing across five tabs starts five requests (Five Components, One Request).
semantics role="tablist" with an aria-label, containing role="tab" buttons that each carry aria-selected and aria-controls. Each role="tabpanel" is labelled by its tab with aria-labelledby, carries tabindex="0" so its content is reachable, and is hidden when inactive.
| Tab | Enters the tab strip at the selected tab, then leaves the strip into the active panel. The strip is one tab stop, not one per tab. |
| Arrow Left / Right | Moves between tabs, wrapping. Vertical tab lists use Up/Down and declare aria-orientation="vertical". |
| Home / End | First and last tab. |
| Enter / Space | Activates the focused tab — required under manual activation, harmless under automatic. |
| Delete | Where tabs are closeable, removes the focused tab and moves focus to a neighbour. Optional, and if offered, must have a visible control too. |
- — Roving tabindex across the strip: the selected tab carries
tabindex="0", every other tabtabindex="-1". - — Focus stays in the strip while arrowing. Activation does not move focus into the panel — the user moves there with Tab when they are ready.
- — The panel has
tabindex="0"so that a panel with no focusable content is still reachable and readable. - — When a tab is removed, focus and selection move to a neighbouring tab, never to
body.
- — On focusing a tab: name, role "tab", selected state, and position — "Billing, tab, 2 of 4, not selected".
- — On activation: the newly selected state, from
aria-selectedchanging. No live region. - — On moving into the panel: the panel's name, which comes from its tab through
aria-labelledby.
usually broken by Putting every tab in the tab order with tabindex="0" and no arrow-key handling. It looks correct in a keyboard walkthrough — every tab is reachable — and it is not the tabs pattern: the screen reader announces a tab list, the user reaches for the arrow keys, and nothing happens.
1<div class="tabs">2 <div role="tablist" aria-label="Account settings">3 <button role="tab" id="tab-profile"4 aria-selected="true" aria-controls="panel-profile"5 tabindex="0">Profile</button>6 <button role="tab" id="tab-billing"7 aria-selected="false" aria-controls="panel-billing"8 tabindex="-1">Billing</button>9 </div>10 11 <div role="tabpanel" id="panel-profile"12 aria-labelledby="tab-profile" tabindex="0">13 …14 </div>15 <div role="tabpanel" id="panel-billing"16 aria-labelledby="tab-billing" tabindex="0" hidden>17 …18 </div>19</div>Two details carry most of the behaviour. Exactly one tab has tabindex="0" — the strip is one tab stop, and Tab from the selected tab lands in its panel. And hidden on the inactive panel, rather than a CSS class, keeps it out of the accessibility tree and out of the tab order.
Accordion
An accordion is a set of disclosures with an optional constraint that only one is open at a time. It is the simplest of the five patterns and the one most often broken by animation: a panel that is visually collapsed but still present in the accessibility tree and the tab order is content the user can focus and cannot see.
<details> and <summary> provide a native disclosure with keyboard operation, state exposure and a built-in toggle. They are limited — animating them is awkward and the single-open constraint has to be scripted — but for content-heavy accordions they remove the entire specification, and they work before JavaScript has loaded (Hydration).
semantics Each section header is a heading at the correct document level containing a button with aria-expanded and aria-controls. The panel carries hidden when collapsed and is labelled by its trigger. Headings matter here: screen-reader users navigate accordions by heading (Document Structure and Reading Order).
| Tab | Moves through the triggers, and into the panel content of any expanded section. Each trigger is its own tab stop — an accordion is not a composite widget. |
| Enter / Space | Toggles the section. Provided by the native button. |
| Arrow Up / Down | Optional: move between triggers. If offered, Home and End should go to the first and last. |
| Escape | Nothing. An accordion is inline content, not an overlay, and should not respond to Escape. |
- — Focus stays on the trigger when a section is toggled. Do not move focus into the panel — the user Tabs there when they want it.
- — Collapsing a section while focus is inside it must move focus back to that section's trigger, or focus is destroyed.
- — In a single-open accordion, opening one section collapses another; if focus was inside the one that closed, it moves to that section's trigger.
- — Do not scroll the newly opened section into view abruptly while focus is elsewhere; it disorients magnifier users.
- — On focusing a trigger: name, role "button", and expanded or collapsed state.
- — On toggle: the new state, from
aria-expandedchanging. No live region, and no announcement of the panel content. - — Nothing at all when a *different* section collapses as a side effect — which is a reason to prefer multi-open accordions where the content allows.
usually broken by Animating the panel and never applying hidden, so collapsed content stays focusable. The second most common: putting aria-expanded on the panel instead of the trigger, so the state exists in the tree on an element no user ever focuses.
<h3>
<button class="acc-trigger">Shipping options</button>
</h3>
<div class="acc-panel" style="max-height: 0; overflow: hidden">
<a href="/shipping">Full shipping policy</a>
</div>
<!-- Height zero, overflow hidden, still in the accessibility
tree and still in the tab order. Tab focuses a link inside
a panel the user believes is closed, and the focus ring is
drawn somewhere invisible. --><h3>
<button class="acc-trigger"
aria-expanded="false"
aria-controls="panel-shipping">
Shipping options
</button>
</h3>
<div id="panel-shipping" class="acc-panel" hidden>
<a href="/shipping">Full shipping policy</a>
</div>
<!-- Announced as "Shipping options, button, collapsed".
hidden removes the panel from the tree and the tab order.
To animate, animate the panel while it is not hidden and
apply hidden when the transition finishes. -->A collapsed panel must be gone from the accessibility tree and the tab order, not merely zero pixels tall — otherwise focus lands inside invisible content. And the expanded state belongs on the trigger, which is the element the user focuses; on the panel, it is exposed somewhere nobody ever visits.
Form
Forms are the oldest interactive pattern on the platform and the one with the most native behaviour available: labels that associate, required and type constraints, submission on Enter, and validation messages the browser announces itself (Native Forms First).
The part almost always rebuilt badly is error reporting. Errors shown as red text next to a field, with no programmatic connection and no aria-invalid, are invisible to a screen reader; a submit button that silently does nothing when the form is invalid leaves the user with no path forward at all.
The reliable design is: every field labelled and described, errors connected to their fields, an error summary at the top that focus moves to on a failed submit, and each summary entry a link to the field it describes (Errors People Can Actually Perceive).
semantics A real form element. Every control has a visible label associated by for/id. Hints and errors are connected with aria-describedby; invalid fields carry aria-invalid="true". Related controls are grouped in a fieldset with a legend. Required fields use the required attribute, not an asterisk alone.
| Tab / Shift+Tab | Moves between fields in DOM order, which must match visual order. |
| Enter | Submits from any single-line text input. This is native behaviour users rely on; a form whose submit is a div with a click handler loses it (Submission: Method, Encoding and Doing It Once). |
| Space | Toggles checkboxes and radios; does not submit. |
| Arrow keys | Move within a radio group — a radio group is one tab stop, natively. |
- — On a failed submit, move focus to the error summary at the top of the form, whose entries link to each invalid field.
- — If there is exactly one error, focusing that field directly is acceptable and often kinder — but pick one behaviour and keep it.
- — Never move focus on every keystroke or on blur-time validation. Validate on submit, and on blur only to *remove* an error once it is fixed.
- — Keep the submit button focusable even when the form is invalid —
aria-disabledplus a handler that refuses, rather thandisabled, so a keyboard user can reach it and be told why (The Rules of ARIA).
- — On focusing a field: label, then the hint and error text from
aria-describedby, then "invalid" fromaria-invalid, then "required" if applicable. - — On failed submit: the error summary, announced by the focus move landing on it — "There are 2 problems with this form".
- — On success: the outcome through the page's status region, or an announcement carried by the navigation to the next view (Live Regions and Announcement).
usually broken by Errors that exist only as red text and a red border. Nothing is connected with aria-describedby, nothing is marked aria-invalid, the summary does not exist, and the disabled submit button means a screen-reader user reaches the end of the form with no error, no focus target and no way to find out what is wrong.
1<form novalidate>2 <div class="field">3 <label for="email">Email address</label>4 5 <p id="email-hint" class="hint">6 We will only use this to send your receipt.7 </p>8 9 <input id="email" name="email" type="email"10 autocomplete="email"11 aria-describedby="email-hint email-error"12 aria-invalid="true" />13 14 <p id="email-error" class="error">15 <svg aria-hidden="true" focusable="false"><use href="#warn" /></svg>16 Enter an email address in the format name@example.com17 </p>18 </div>19 20 <button type="submit">Create account</button>21</form>aria-describedby lists both ids, in reading order, so the hint and the error are announced after the label. aria-invalid is what makes the screen reader say "invalid" — the red border does not. The icon plus the sentence mean the error is not carried by colour alone (Contrast, Colour and Motion).
How to build it
Most important first.
- Build each of these once, in a shared component, with its contract written down next to it — and never again in a feature branch (What a Component Owes Its Caller).
- Start from the native element wherever one exists, and treat replacing it as a decision that needs a reason recorded in the code.
- Copy the established key set. Do not derive your own; users already know these.
- Make state exposure derive from the same value that drives rendering, so no code path can update one without the other.
- Write the focus rules explicitly: where focus goes on open, where it is contained, where it returns on close, and what happens when the return target no longer exists.
- Test each pattern with the keyboard and with at least two screen readers before it enters the design system, because everything downstream inherits whatever you got wrong (Design Systems).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- These five patterns account for a large share of the interactive surface of most applications; getting them right in a component library fixes them everywhere at once.
- Each specification below states role, name, state, keys, focus and announcements. If a component in your codebase cannot fill in all six, the missing rows are unimplemented behaviour, not documentation debt.
- Automated tooling can check a fraction of these — a missing name, a dangling
aria-controls,aria-hiddenon a focusable element — and cannot check a focus trap, an activation model or an announcement. Manual keyboard and screen-reader testing is not optional here (Accessibility Testing). - Screen readers differ most on exactly these patterns, because they involve the composite roles where implementations diverge. One product passing is evidence about one product.
What can go wrong
- The modal that does not restore focus, so every dialog interaction sends a keyboard user back to the top of the page.
- The menu that closes on blur, so clicking an item inside it is a race between blur and click and the action sometimes does not fire.
- Tabs with automatic activation over expensive panels, so arrowing across the strip triggers five data loads (Cancelling a Request Nobody Is Waiting For).
- An accordion that animates height and forgets to set
hidden, leaving collapsed content in the accessibility tree and in the tab order. - A form that moves focus to the first invalid field and also announces an error summary, producing a double announcement and a race (Live Regions and Announcement).
- The mitigation failing: a shared modal component that is correct, wrapped by a feature-level convenience wrapper that renders its own overlay and defeats the containment.
- Open animation versus focus move: focusing an element that is still transitioning can scroll the viewport to a position the element is about to leave.
- Blur versus click inside a menu: closing on
blurcancels the click that caused it. Close onfocusoutwith a check of the new target, or on an explicit outside-pointer event. - Tab activation versus data load: arrowing rapidly across tabs with automatic activation starts overlapping requests, and a late response can render into the wrong panel (Out-of-Order Responses).
- Focus restoration versus unmount: restoring focus to a trigger that was removed by the same action the dialog performed silently does nothing (Focus Management).
- Error announcement versus focus move on submit: doing both in the same frame races, and the outcome depends on the screen reader (Live Regions and Announcement).
- A confirmation dialog is a user-experience device and never an authorisation control: the destructive request must be authorised on the server regardless of what the client displayed (Authorization-Aware UI).
- A modal that does not make the background inert leaves the page behind it operable, which is the same property a clickjacking overlay exploits (Clickjacking and Framing).
- Client-side form validation is a usability affordance; every rule must be enforced again on the server (Native Validation and Its Limits).
- "A dropdown is a menu." Usually it is not.
role="menu"is for application menus of commands; a navigation dropdown is a list of links, and a select-like control is a listbox (Semantics Before ARIA). - "Tabs are just buttons that swap content." Tabs are a composite widget with one tab stop, arrow navigation, an activation model and a tab-to-panel relationship.
- "The modal works — I can Tab through it." The question is whether you can Tab *out* of it, and where focus goes when it closes.
- "Errors are shown, so the form is accessible." Shown to whom, connected to which field, and announced when?
- "The component library handles accessibility." Only for the parts it implements: it cannot supply your labels, your error text or your focus-restoration target.
Measuring it, and what changes in the field
- Keyboard walkthrough per pattern: open, move, activate, escape, close, and check where focus landed at each step.
document.activeElementafter every step, and afocusinlog for the whole flow.- At least two screen readers, on two platforms, for anything entering a design system.
- End-to-end tests that assert focus position and ARIA state after each interaction — the only way these contracts survive refactoring (End-to-End Testing, Component Testing).
- On mobile with a screen reader, gestures replace keys entirely: VoiceOver on iOS and TalkBack on Android navigate by swipe, and a pattern that depends on arrow keys must still be operable by their exploration model.
- On a slow device, an animated modal can be visible before its focus move has run, so the user sees a dialog that does not yet have focus (The Frame Budget).
- With long lists inside menus and listboxes, typeahead becomes essential and arrow-only navigation becomes unusable.
- Before hydration, every one of these patterns is inert unless it was built on native elements —
<details>and<form>work immediately, a JavaScript accordion does not (Hydration).
- The native
<dialog>costs you control over the top layer and its stacking, which sometimes conflicts with an established z-index architecture — in exchange for containment, inertness and Escape being someone else's bug to fix. - Manual tab activation is more correct for expensive panels and slightly slower for cheap ones; automatic is friendlier for lightweight content and pathological for anything that fetches.
- A shared, specified component library is more expensive to build than five bespoke implementations and is the only way these contracts stay true across a codebase over time (Over-Componentization).
- Screen-reader testing is manual, slow and unglamorous, and there is no substitute for it in this lesson.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe role structures and key sets are the documented ARIA Authoring Practices patterns and are implemented consistently enough across engines to be relied on. Where implementations vary it is in announcement, not in structure.
- PLATFORM-SPECIFICComposite widgets are where screen readers diverge most: NVDA and JAWS switch between browse and forms mode differently inside dialogs and tab lists, VoiceOver on macOS announces group boundaries the Windows readers omit, and VoiceOver on iOS plus TalkBack on Android replace arrow keys with swipe gestures entirely — so a tab strip that is perfect with a keyboard on Windows still needs verifying on a touch screen reader.
- BROWSER-SPECIFIC
<dialog>,showModal(),inertand the top layer reached the three engines at different times and still differ in details such as whether the first focusable element or the dialog itself receives initial focus, and how::backdropinteracts with existing stacking contexts. Your browser support floor decides how much of the modal you implement yourself.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — this is where automated accessibility testing runs out. A rule set can find a missing name or a dangling
aria-controls; only a person with a keyboard and a screen reader can tell you whether the focus trap traps, whether Escape returns focus to the trigger, and whether the error announcement made sense. - — Software Design — these five contracts are the clearest example of a component interface that includes behaviour rather than only props: the keys, the focus rules and the announcements are as much part of the API as the arguments.