Design rejected the native control, so you are building a custom one. How do you make it accessible?
Build it from the nearest native element so role, focusability and key handling come free, decide the keyboard model before the visuals, move and restore focus deliberately, and add ARIA only for state the markup cannot express - it changes what assistive technology announces but adds no behaviour.
What the interviewer is scoring
- Does the candidate start from native elements instead of from a div plus a role
- Whether they state the keyboard model, including Escape and where Tab goes, before writing any markup
- That they treat returning focus to the trigger as part of closing, not as a nicety
- Whether they can say what ARIA does and does not change at runtime
- Can they name a case where wrong ARIA is worse for a user than no ARIA
Answer
Semantics are the accessibility layer, not a formality
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.
<button id="sort-btn" aria-expanded="false" aria-controls="sort-panel">Sort</button>
<!-- Links, so this is a plain list. role="menu" would promise arrow-key
navigation that this component does not implement. -->
<ul id="sort-panel" hidden>
<li><a href="?sort=newest">Newest</a></li>
<li><a href="?sort=price">Price</a></li>
</ul>
const btn = document.querySelector('#sort-btn');
const panel = document.querySelector('#sort-panel');
function setOpen(open) {
// The announced state and the rendered state must change together. Updating
// `hidden` alone leaves screen reader users told it is still collapsed.
btn.setAttribute('aria-expanded', String(open));
panel.hidden = !open;
}
btn.addEventListener('click', () => setOpen(btn.getAttribute('aria-expanded') === 'false'));
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || panel.hidden) return;
setOpen(false);
btn.focus(); // Without this the user is returned to the top of the document.
});
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.
Likely follow-ups
- When is a roving tabindex the right choice and when would you use aria-activedescendant instead?
- A designer wants the focus ring removed. What do you offer instead, and which success criterion are you protecting?
- How would you test this without a screen reader, and what does that testing miss?
- Your dropdown is a list of navigation links. Why is role=menu the wrong choice for it?
Related questions
- This form marks invalid fields with red text and a red border. What has to change?mediumAlso on accessibility and wcag4 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- How do goroutines leak, and how would you find a leak in a running service?mediumSame kind of round: concept5 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumSame kind of round: concept4 min
- Your endpoint takes a typed request body and it still crashed on a missing field. How is that possible when it compiled?mediumSame kind of round: concept4 min
- A dashboard has been showing the same temperature for two days and nobody noticed. Walk the path and tell me what would have caught it.hardSame kind of round: scenario6 min
- Everyone in the business calls it the fax queue, and nothing has been faxed since 2014. Do you keep that name in the code?mediumSame kind of round: concept4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min