AccessibilityGENERALPLATFORM-SPECIFICBROWSER-SPECIFIC

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.

The question

What exactly does each of the standard interactive components owe a keyboard and a screen reader?

The user intent

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.

The obvious build

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.

Why it breaks

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.

How it breaks in a real browser
  • 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 div leaves 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-expanded lives 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).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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> with showModal() 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-expanded on the trigger, aria-selected on the tab, aria-invalid on the field.

What this makes the browser do

And which of it is avoidable.

  • <dialog> with showModal() 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 an inert toggle: 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 hidden on 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.

Which pattern is this, really?

A thing opens and shows some options. Which pattern is it?

Disclosure

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.

Menu (`role="menu"`)

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.

Listbox or combobox

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.

Navigation dropdown

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.

Modal dialog

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.

accessibility specModal dialogModal dialog contract

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.

EscapeCloses, 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+TabCycles within the dialog only. Reaching the last element and pressing Tab returns to the first, in both directions.
EnterSubmits the dialog's form, if it has one — which is why the confirming action should be a submit button and not a click handler.
Focus
  • 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 inert so 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.
Announces
  • 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).

The native modal, and the parts still left to you
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 showModal
20 // 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 the
25 // previously focused element in most cases, but if the action
26 // 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.

accessibility specMenu button with a command menu (row actions, editor menu)Menu button contract (for an actual command menu)

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 ArrowOpens 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 / DownMoves between items, wrapping at both ends. The menu is one tab stop; Tab closes it and moves on.
Home / EndFirst and last item.
Printable charactersTypeahead: jumps to the next item starting with that character. Essential once a menu has more than a handful of items.
EscapeCloses and returns focus to the button, without invoking anything.
EnterInvokes the focused item and closes the menu.
Focus
  • 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; use focusout with a check of relatedTarget, 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.
Announces
  • 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.

The same visual dropdown, two different contracts
Navigation links announced as an application menu
<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. -->
A disclosure containing a list of links
<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

PLATFORM-SPECIFICAnnouncement of tab position and selection varies: JAWS and NVDA read the selected state and the set position reliably, VoiceOver on macOS sometimes omits the position unless the tabs are direct children of the tablist, and on iOS and Android the arrow-key model is replaced by swipe navigation entirely — so the pattern needs verifying on a touch screen reader as well as with a keyboard.

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).

accessibility specTabsTabs contract

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.

TabEnters 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 / RightMoves between tabs, wrapping. Vertical tab lists use Up/Down and declare aria-orientation="vertical".
Home / EndFirst and last tab.
Enter / SpaceActivates the focused tab — required under manual activation, harmless under automatic.
DeleteWhere tabs are closeable, removes the focused tab and moves focus to a neighbour. Optional, and if offered, must have a visible control too.
Focus
  • Roving tabindex across the strip: the selected tab carries tabindex="0", every other tab tabindex="-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.
Announces
  • 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-selected changing. 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.

The role structure, and the attributes that make it a widget
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).

accessibility specAccordionAccordion contract

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).

TabMoves 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 / SpaceToggles the section. Provided by the native button.
Arrow Up / DownOptional: move between triggers. If offered, Home and End should go to the first and last.
EscapeNothing. An accordion is inline content, not an overlay, and should not respond to Escape.
Focus
  • 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.
Announces
  • On focusing a trigger: name, role "button", and expanded or collapsed state.
  • On toggle: the new state, from aria-expanded changing. 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.

Collapsing a panel
Collapsed with CSS
<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. -->
State on the trigger, panel removed from the tree
<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).

accessibility specForm with validationForm and error-handling contract

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+TabMoves between fields in DOM order, which must match visual order.
EnterSubmits 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).
SpaceToggles checkboxes and radios; does not submit.
Arrow keysMove within a radio group — a radio group is one tab stop, natively.
Focus
  • 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-disabled plus a handler that refuses, rather than disabled, so a keyboard user can reach it and be told why (The Rules of ARIA).
Announces
  • On focusing a field: label, then the hint and error text from aria-describedby, then "invalid" from aria-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.

A field with a hint, an error, and the connections that make them exist
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.com
17 </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-hidden on 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

Failure modes
  • 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.
What can arrive out of order
  • 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 blur cancels the click that caused it. Close on focusout with 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).
Security
  • 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).
Misreads
  • "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

How you would see this
  • Keyboard walkthrough per pattern: open, move, activate, escape, close, and check where focus landed at each step.
  • document.activeElement after every step, and a focusin log 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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(), inert and 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 ::backdrop interacts 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.

Domains that do not exist yet
  • 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.