Common widget patterns
Four patterns you'll meet in every codebase — buttons, custom checkboxes, disclosures, and tabs.
So far we've talked about ARIA at the attribute level. Now let's look at whole patterns — recipes you'll need over and over. Each of these can be done well with a tiny amount of ARIA, or done badly with a lot of it. The bad versions ship constantly. Knowing the canonical shape will save you from reinventing them in a way that ends up harming users.
Sometimes design or framework constraints make a real <button> impractical — usually inside a <a>, <div>, or a custom component. If you must, here is the minimum you owe a fake button:
role="button"so it's announced as a button.tabindex="0"so the keyboard can reach it.- A keydown handler that activates on both Enter and Space (Space is button-only; pressing Space on a real
<a>scrolls the page). - The same focus styles as a real button.
- An accessible name (text content or
aria-label).
<div
role="button"
tabindex="0"
onclick="send()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); send(); }"
>
Send
</div>That is more code than <button>Send</button> and easier to break. Rule 1: use the native element. The point of this section is that if you can't, the fake-button pattern is non-negotiably this whole list.
If your "fake button" needs to behave like a toggle (mute on/off, bold/not-bold), add aria-pressed="true" or "false" and update it on every click.
Custom checkboxes
The same rule applies, harder. A real <input type="checkbox"> handles focus, labelling (when paired with <label>), the indeterminate state, the checked state, keyboard activation (Space), and form submission. None of that comes for free with a div.
If a designer insists on a checkbox style that the native input can't easily produce, the modern answer is: keep the native input, hide it visually with .visually-hidden styles, and style the <label> instead. This is the CSS-only custom checkbox pattern, and it's almost always better than reinventing the widget in ARIA.
<label class="custom-check"> <input type="checkbox" name="newsletter"> <span class="custom-check__box" aria-hidden="true"></span> <span class="custom-check__label">Subscribe to newsletter</span> </label>
The <input> provides all the semantics; CSS does the visual work using :checked and :focus-visible selectors on the input to style the sibling <span>.
If for some reason you really cannot use a native input, the ARIA recipe is:
<div
role="checkbox"
aria-checked="false"
tabindex="0"
onclick="toggle(this)"
onkeydown="if (event.key === ' ') { event.preventDefault(); toggle(this); }"
>
Subscribe to newsletter
</div>You're now responsible for keeping aria-checked in sync, handling Space (not Enter — checkboxes activate on Space only), styling for keyboard focus, and gathering the value on form submit. This is a lot to maintain. Use the native one.
The disclosure pattern
A disclosure is a button that toggles whether a chunk of content is visible. "Read more," accordion sections, "Show advanced settings." The pattern is small, well-supported, and often reached for incorrectly (people use role="tab" for things that should be disclosures).
Three pieces:
- A
<button>(a real one) witharia-expanded="true"or"false". - The content panel, identified somehow.
- Optionally,
aria-controls="panelId"on the button so screen readers know which region the button affects.
<button aria-expanded="false" aria-controls="advanced"> Show advanced settings </button> <div id="advanced" hidden> ... advanced content ... </div>
When the button is clicked, JavaScript flips aria-expanded between true and false and toggles the hidden attribute on the panel. That's the whole pattern.
Native HTML has <details> and <summary> for exactly this. They handle aria-expanded, focus, keyboard, and the show/hide automatically. Reach for them first.
aria-controls is one of those attributes screen readers vary on — some announce it, some don't. Set it when the relationship is non-obvious from the layout (the panel is far from the button); skip it when the panel is the next sibling and the connection is visually clear.
role="tab" and tab panels
Tabs look like disclosures but are a different pattern, with stricter keyboard expectations. A tab list is a set of mutually exclusive options — picking one shows its panel, and picking it hides every other panel.
The minimum ARIA shape:
<div role="tablist" aria-label="Account sections"> <button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">Profile</button> <button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button> </div> <div role="tabpanel" id="p1" aria-labelledby="t1">...</div> <div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div>
Three things make this pattern hard to fake:
- Selection follows focus, not Tab. Use
tabindex="0"on the active tab andtabindex="-1"on the others. Inside the tablist, arrow keys move between tabs; Tab moves out of the tablist into the panel. aria-selectedmust update as the user changes tabs.- The panel is labelled by its tab via
aria-labelledby— that's the link that lets screen-reader users discover what's inside.
If you can't commit to the keyboard contract above, tabs are the wrong pattern — use disclosures instead, where each section opens independently with no roving focus required.
A tablist where every tab is in the Tab order is the most common bug. Sighted users may not notice; keyboard users hit Tab six times to reach the panel. Use roving tabindex.
Try it yourself
<div>. Per rule 1, what's the absolute minimum ARIA + keyboard wiring it needs?