Skip to content

Last updated:

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>.

The UiModal component should announce itself as a dialog, trap focus, and label its content.

Current Implementation

vue
<!-- 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>
vue
<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:

AttributePurpose
role="dialog"Tells screen readers this is a dialog
aria-modal="true"Indicates content behind the dialog is inert
aria-labelledbyPoints to the modal title for screen reader announcement
aria-hidden="true" on backdropHides the decorative backdrop from screen readers

Focus Trapping

When the modal opens, focus should be trapped inside it. Add a focus trap utility:

ts
// 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:

vue
<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>
AttributePurpose
aria-busy="true"Indicates the button is processing an action
aria-disabledMirrors the disabled state for ARIA consumers
aria-hidden="true" on spinner SVGHides the decorative spinner from screen readers
sr-only spanProvides "Loading..." text for screen readers

Icon-Only Buttons

When a button has no visible text (only an icon), add aria-label:

vue
<!-- 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

vue
<input :id="id" ... />
<div v-if="error" class="mt-1 text-xs text-red-500 font-medium">
  {{ error }}
</div>

Enhanced with ARIA

vue
<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>
AttributePurpose
aria-invalid="true"Tells screen readers the input has a validation error
aria-describedbyLinks the error message to the input for announcement
aria-required="true"Indicates the field is mandatory
role="alert" on errorAnnounces 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:

vue
<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:

html
<th v-for="column in columns" :key="column.key" scope="col" class="px-6 py-4">
  {{ column.label }}
</th>

Dropdown menus should follow the ARIA menu or listbox pattern:

vue
<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

vue
<div
  v-if="visible"
  class="fixed bottom-6 right-6 z-[9999] ..."
>
  <span>{{ message }}</span>
  <button @click="close">&times;</button>
</div>

Enhanced with ARIA

vue
<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">&times;</button>
</div>
AttributePurpose
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 buttonDescribes 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:

vue
<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:

vue
<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:

css
.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;
}

CPR - Clinical Patient Records