The order in which you build the component determines how much work the rest of it will be. A <button> arrives with a role in the accessibility tree, a place in the tab order, activation on both Enter and Space, a disabled state that removes it from the tab order, a name computed from its content, and rendering that survives forced-colours mode. Every one of those is a thing you would otherwise write, test on three screen readers, and get subtly wrong. So the first question is not "which ARIA roles does this pattern need" but "how much of this can I get by wrapping a real control and restyling it".
That is often more than people assume. A custom checkbox is a real <input type="checkbox"> visually hidden with a styled sibling. A segmented control is a <fieldset> of radios. A modal is <dialog>, which brings the top layer, Escape handling, focus containment and a ::backdrop pseudo-element. You only enter genuinely custom territory when no native element expresses the interaction — a combobox with async suggestions, a tree, a sortable data grid — and then you are implementing a pattern, not decorating an element.
Decide the keyboard model before the pixels
Write the key map down first, because it constrains the DOM you are allowed to build. Two conventions carry most of it. Tab moves between widgets and never within a composite one, so a listbox with fifty options must be one tab stop, not fifty. Arrow keys move within, and you implement that either with a roving tabindex — exactly one option carries tabindex="0", the rest carry -1, and you move both the attribute and DOM focus together — or with aria-activedescendant, where focus stays on the container and you point an attribute at the active option. Escape dismisses anything that opened, Home and End jump to the ends, and Space versus Enter follows the native control you are imitating.
Then check the things that are easy to break by accident. Nothing may be reachable by pointer but not by keyboard, which is WCAG 2.1.1. Nothing may trap the keyboard with no way out, 2.1.2. Whatever has focus must be visibly focused, 2.4.7, and under WCAG 2.2 must not be hidden behind a sticky header or the overlay you just opened, 2.4.11.
Focus is state, so manage it explicitly
Opening a layer moves focus into it; closing a layer puts focus back on the element that opened it. Skip the second half and a keyboard user's next Tab starts from <body>, which after dismissing a dialog halfway down a long page means starting the page again. Use tabindex="-1" to make a container programmatically focusable without adding it to the tab order, never a positive tabindex, and while an overlay is open make the rest of the page unreachable with the inert attribute or by using showModal(), which does it for you. Style the ring with :focus-visible so pointer users do not see it and keyboard users always do; removing outline with nothing in its place is the single most common accessibility regression in a redesign.
A dropdown, end to end
Here is the smallest complete version of the pattern people most often get wrong. A trigger that reveals a list of links is a disclosure, and disclosures need one attribute pair plus focus discipline.
Two attributes and a focus call. Note what is absent: no role, no aria-haspopup, no tabindex. The <button> and the <a> elements already carry their roles, and hidden already removes the collapsed list from the accessibility tree, so adding ARIA would only create a second source of truth to keep in sync. If the same control selected a value rather than navigating, it would become the combobox-plus-listbox pattern, and then you would owe the roles, aria-selected, aria-activedescendant, arrow keys and typeahead — all of them, because the roles announce that they exist.
ARIA is a promise, and the user hears it before you have kept it
This is what separates an answer that has shipped this from one that has read about it. ARIA has no runtime behaviour whatsoever; it edits the accessibility tree and nothing else. role="button" on a <div> makes a screen reader say "button" while leaving the element unfocusable and unresponsive to Enter and Space, so the user is now told about a control they cannot operate. Worse, roles change the mode assistive technology operates in — announcing role="listbox" tells a screen reader to stop reading the page linearly and start expecting arrow-key option navigation, so if you have not implemented that, you have not merely failed to help, you have removed the browsing behaviour that would otherwise have worked.
The same holds for names. An aria-label overrides the visible text rather than supplementing it, so labelling a button reading "Save draft" as aria-label="Submit" breaks voice control, where the user says the words they can see, and violates 2.5.3 Label in Name. And aria-hidden="true" on anything focusable produces a control that exists in the tab order but not in the accessibility tree, which is the most disorienting state you can put a screen reader user in.
Reach for ARIA when you have state the markup genuinely cannot express, and treat every role you add as a set of keyboard behaviours you have now committed to implementing.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).