ARIA Practices
This document covers how to apply ARIA (Accessible Rich Internet Applications) roles, states, and properties to CPR's component library. ARIA attributes provide semantic information to assistive technologies when native HTML semantics are insufficient.
Rule of Thumb
Use native HTML elements and attributes first. Only add ARIA when there is no native HTML equivalent for the behavior.
For example, <button> already communicates "this is a button" to screen readers. Adding role="button" to a <div> is a workaround, not best practice. CPR's UiButton correctly uses <button>.
Modal Dialogs (UiModal)
The UiModal component should announce itself as a dialog, trap focus, and label its content.
Current Implementation
<!-- UiModal renders to body via Teleport -->
<Teleport to="body">
<div v-if="modelValue" class="fixed inset-0 z-50 ...">
<div class="absolute inset-0 bg-black/50" @click="close()" />
<div class="relative w-full bg-white rounded-3xl ...">
<h2 class="text-xl font-bold text-white">{{ title }}</h2>
<!-- body slot -->
</div>
</div>
</Teleport>Recommended ARIA Enhancement
<div
v-if="modelValue"
role="dialog"
aria-modal="true"
:aria-labelledby="`modal-title-${id}`"
class="fixed inset-0 z-50 ..."
>
<div class="absolute inset-0 bg-black/50" @click="close()" aria-hidden="true" />
<div class="relative w-full bg-white rounded-3xl ...">
<h2 :id="`modal-title-${id}`" class="text-xl font-bold text-white">
{{ title }}
</h2>
<!-- body slot -->
</div>
</div>Key attributes:
| Attribute | Purpose |
|---|---|
role="dialog" | Tells screen readers this is a dialog |
aria-modal="true" | Indicates content behind the dialog is inert |
aria-labelledby | Points to the modal title for screen reader announcement |
aria-hidden="true" on backdrop | Hides the decorative backdrop from screen readers |
Focus Trapping
When the modal opens, focus should be trapped inside it. Add a focus trap utility:
// Recommended focus trap for UiModal
const trapFocus = (container: HTMLElement) => {
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
container.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
});
first?.focus();
};Button States (UiButton)
Loading State
When a button is loading, it is disabled and shows a spinner. Communicate this to screen readers:
<button
:type="type"
:disabled="disabled || loading"
:aria-busy="loading"
:aria-disabled="disabled || loading"
:class="buttonClasses"
>
<svg v-if="loading" class="animate-spin ..." aria-hidden="true">...</svg>
<span v-if="loading" class="sr-only">Loading...</span>
<slot />
</button>| Attribute | Purpose |
|---|---|
aria-busy="true" | Indicates the button is processing an action |
aria-disabled | Mirrors the disabled state for ARIA consumers |
aria-hidden="true" on spinner SVG | Hides the decorative spinner from screen readers |
sr-only span | Provides "Loading..." text for screen readers |
Icon-Only Buttons
When a button has no visible text (only an icon), add aria-label:
<!-- Close button in UiModal -->
<button
class="p-1 text-white/70 hover:text-white ..."
aria-label="Close dialog"
@click="close"
>
<XMarkIcon class="w-6 h-6" aria-hidden="true" />
</button>Form Validation Errors
Connect error messages to their inputs using aria-describedby and indicate invalid state with aria-invalid:
Current UiInput Error Display
<input :id="id" ... />
<div v-if="error" class="mt-1 text-xs text-red-500 font-medium">
{{ error }}
</div>Enhanced with ARIA
<input
:id="id"
:aria-invalid="!!error"
:aria-describedby="error ? `${id}-error` : undefined"
:aria-required="required"
...
/>
<div
v-if="error"
:id="`${id}-error`"
role="alert"
class="mt-1 text-xs text-red-500 font-medium"
>
{{ error }}
</div>| Attribute | Purpose |
|---|---|
aria-invalid="true" | Tells screen readers the input has a validation error |
aria-describedby | Links the error message to the input for announcement |
aria-required="true" | Indicates the field is mandatory |
role="alert" on error | Announces the error when it appears |
Table Accessibility (UiTable)
The UiTable uses semantic <table>, <thead>, <th>, <tbody>, <td> elements, which is the correct foundation.
Enhancements for Clickable Rows
When rows are clickable, add keyboard support and ARIA:
<tr
v-for="(row, index) in data"
:key="row.id || index"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
:aria-label="clickable ? `View details for row ${index + 1}` : undefined"
@click="clickable && $emit('row-click', row)"
@keydown.enter="clickable && $emit('row-click', row)"
>Column Scope
Add scope to header cells:
<th v-for="column in columns" :key="column.key" scope="col" class="px-6 py-4">
{{ column.label }}
</th>Dropdown Menus (UiDropdown)
Dropdown menus should follow the ARIA menu or listbox pattern:
<div class="relative">
<button
:aria-expanded="isOpen"
aria-haspopup="listbox"
@click="toggle"
>
{{ selectedLabel }}
</button>
<ul
v-if="isOpen"
role="listbox"
:aria-label="label"
>
<li
v-for="option in options"
:key="option.id"
role="option"
:aria-selected="option.id === selectedId"
tabindex="0"
@click="select(option)"
@keydown.enter="select(option)"
>
{{ option.name }}
</li>
</ul>
</div>Alert and Snackbar Announcements (UiSnackBar)
The UiSnackBar component should announce messages to screen readers using a live region:
Current Implementation
<div
v-if="visible"
class="fixed bottom-6 right-6 z-[9999] ..."
>
<span>{{ message }}</span>
<button @click="close">×</button>
</div>Enhanced with ARIA
<div
v-if="visible"
role="alert"
aria-live="assertive"
aria-atomic="true"
class="fixed bottom-6 right-6 z-[9999] ..."
>
<span>{{ message }}</span>
<button aria-label="Dismiss notification" @click="close">×</button>
</div>| Attribute | Purpose |
|---|---|
role="alert" | Announces the message immediately to screen readers |
aria-live="assertive" | Interrupts current screen reader output for urgent messages |
aria-atomic="true" | Announces the entire content, not just changes |
aria-label on close button | Describes the button's purpose (the x character is not descriptive) |
Use aria-live="polite" instead of assertive for informational (non-urgent) messages.
Confirm Dialog (UiConfirmDialog)
The confirmation dialog should be an alert dialog:
<div
role="alertdialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="messageId"
>
<h2 :id="titleId">{{ title }}</h2>
<p :id="messageId">{{ message }}</p>
<button @click="cancel">Cancel</button>
<button @click="confirm">Confirm</button>
</div>The alertdialog role tells screen readers this requires user attention and a response.
Screen Reader Only Text
Use the Tailwind sr-only class to provide text that is visible only to screen readers:
<button @click="deletePatient">
<TrashIcon class="w-5 h-5" aria-hidden="true" />
<span class="sr-only">Delete patient record</span>
</button>The sr-only class applies:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}