Dynamic content and ARIA
Learn the ARIA contract
Treat an ARIA role as a promise to implement the matching states, properties, focus, and keyboard behavior.
ARIA is a set of attributes that change what the accessibility tree says about an element. role="tab" makes a div report itself as a tab. That’s all it does. It doesn’t make it focusable, doesn’t add arrow key handling, doesn’t switch panels, doesn’t style anything. The screen reader now tells the user “this is a tab”, and the user expects every behavior a tab has.
That expectation is the contract. A role is a promise. The rest of the promise is yours to implement.
The tab pattern, fully
The ticket section of the registration page shows “What’s included” and “Refunds” as two tabs. The flawed version put role="tab" on two links and stopped there. Screen reader users heard “tab”, then arrow keys did nothing, and the panels weren’t connected to anything.
The ARIA Authoring Practices Guide documents what a tab widget must do. Here’s the markup that keeps the promise:
<div role="tablist" aria-label="Ticket details">
<button role="tab" id="tab-includes" aria-selected="true" aria-controls="panel-includes">
What's included
</button>
<button role="tab" id="tab-refunds" aria-selected="false" aria-controls="panel-refunds" tabindex="-1">
Refunds
</button>
</div>
<div role="tabpanel" id="panel-includes" aria-labelledby="tab-includes">...</div>
<div role="tabpanel" id="panel-refunds" aria-labelledby="tab-refunds" hidden>...</div>
Each role brings states and properties with it. A tab needs aria-selected and aria-controls. A tabpanel needs a name, here from aria-labelledby. Only the selected tab is in the Tab order; the others have tabindex="-1", so the whole list is one Tab stop and arrows move inside it.
The behavior half
Now the keyboard and state handling that the roles promised:
const tabs = [...document.querySelectorAll('[role="tab"]')]
function activate(tab) {
tabs.forEach(t => {
const selected = t === tab
t.setAttribute('aria-selected', selected)
t.tabIndex = selected ? 0 : -1
document.getElementById(t.getAttribute('aria-controls')).hidden = !selected
})
tab.focus()
}
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => activate(tab))
tab.addEventListener('keydown', event => {
if (event.key === 'ArrowRight') activate(tabs[(i + 1) % tabs.length])
if (event.key === 'ArrowLeft') activate(tabs[(i - 1 + tabs.length) % tabs.length])
})
})
Tab into the list and VoiceOver says “What’s included, selected, tab, 1 of 2”. Press Right Arrow and you hear “Refunds, selected, tab, 2 of 2” while the panel below swaps. That “1 of 2” comes from the tablist role, and “selected” from aria-selected. Neither exists until you write them.
The audit-silencing role
The mistake this lesson is about: a tool flags a widget, someone adds a role to make the warning go away, and the interaction stays broken. Now the page is worse than before. Earlier, a screen reader user heard a plain link and used it as a link. Now they hear “tab”, try the arrow keys, and nothing happens.
My rule: before typing a role, open the APG page for that pattern and list every keyboard interaction and every state it requires. If you’re not going to implement the list, don’t add the role. Use the native element you have and accept that it’s a link or a button.
Try this on one custom widget in your project: write down every behavior its role promises, then check them off against the code. Whatever is left unchecked is your finding.
Lesson completed