Accessible progressive UI
Keep semantics in sync
Update aria-expanded, hidden state, labels, errors, and focus together with visual state.
Hiding and showing an element is the visual half of an interaction. The other half is what a screen reader hears: the element’s name, its role, its current state, and where focus is.
Alpine makes the two halves easy to keep together, because both come from the same value. The only way to get them out of sync is to bind one and forget the other.
One value, every attribute
The edit disclosure on an issue row. A button controls a region, and the button must say whether the region is open:
<li x-data="{ expanded: false }">
<button
:aria-expanded="expanded"
aria-controls="issue-42-details"
@click="expanded = !expanded">
Export CSV misses last row
</button>
<div id="issue-42-details" x-show="expanded" x-cloak>
Reported by Marta, 3 days ago.
</div>
</li>
Both aria-expanded and x-show read expanded. Click, and a screen reader announces “Export CSV misses last row, button, expanded”. Click again: “collapsed”. There is no second flag to forget.
aria-controls points at the region’s id. In a real list, generate the id from the issue: :aria-controls="'issue-' + issue.id + '-details'" and :id on the region.
Start from the native pattern
The button is a <button>, so it has the button role, keyboard activation with Enter and Space, and focusability for free. A <div @click> has none of that, and you’d add role, tabindex, and a keydown handler to get back to where <button> started.
Same for the editor. It’s a <form> with <label>s. Labels stay attached to inputs whether or not Alpine is running.
Errors that get announced
When save fails, the message has to reach assistive technology, not just the screen:
<input id="title" name="title" x-model="title"
:aria-invalid="error ? 'true' : null"
:aria-describedby="error ? 'title-error' : null">
<p id="title-error" role="alert" x-show="error" x-text="error"></p>
role="alert" makes the text read aloud when it appears. aria-describedby ties it to the field, so tabbing back to the input repeats the reason. aria-invalid marks the field. All three flip from the same error string. Binding null removes the attribute when there is no error.
Focus is state too
Visual state changed, semantic state changed, and focus is still on the element that was there before. That’s the third thing to keep in sync. We covered the mechanics with $nextTick earlier. The rule here: whenever a change hides the focused element, decide where focus goes on the same line.
The mistake
The common failure is changing a class and stopping. :class="{ open: expanded }" looks done. The panel slides open. A screen reader user presses the button and hears nothing change, because aria-expanded was never bound.
Every time you write x-show, ask what a screen reader should hear. Usually the answer is one more :aria- binding on the same value.
Run the check on your board: open the accessibility tree in your browser’s devtools and expand a row. The button’s “expanded” state should flip. Then unplug the mouse and use the whole board with Tab, Enter, Escape, and Space.
Lesson completed