Accessibility

View Source

Sutra UI is built with accessibility as a core principle. Components use semantic HTML, ARIA patterns, and keyboard behavior where the component is interactive.

Accessibility Targets

Sutra UI components are designed with WCAG 2.1 Level AA criteria in mind:

  • Perceivable - Text alternatives, adaptable content, distinguishable colors
  • Operable - Keyboard accessible, enough time, navigable
  • Understandable - Readable, predictable, input assistance
  • Robust - Compatible with assistive technologies

Keyboard Navigation

Global Patterns

KeyAction
TabMove focus to next focusable element
Shift + TabMove focus to previous focusable element
Enter / SpaceActivate focused element
EscapeClose modal, popover, dropdown

Component-Specific Shortcuts

Tabs

KeyAction
Arrow Left/RightNavigate between tabs
HomeGo to first tab
EndGo to last tab

Dropdown Menu

KeyAction
Arrow Up/DownNavigate menu items
Enter / SpaceActivate item
EscapeClose menu

Select

KeyAction
Arrow Up/DownNavigate options
EnterSelect option
EscapeClose dropdown
Letter key (open listbox)Jump to the next visible option starting with that letter

Accordion

KeyAction
Enter / SpaceToggle accordion panel

Dialog

KeyAction
EscapeClose dialog
TabCycle through focusable elements (trapped)

Command Palette

KeyAction
Arrow Up/DownNavigate results
EnterSelect item
EscapeClose command_dialog

ARIA Attributes by Component

Buttons

<.button>Save</.button>
<!-- Renders with native button semantics -->

<.button size="icon" aria-label="Close">
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</.button>
<!-- Icon buttons MUST have aria-label -->

<.button loading>Saving...</.button>
<!-- Sets aria-busy="true" when loading -->

Form Controls

<.input
  id="email"
  name="email"
  type="email"
  label="Email"
  description="We'll use this for account notifications."
  errors={["Invalid email"]}
/>
<!-- Links label, description, and errors; sets aria-invalid when errors are present -->

Dialog

<.dialog id="confirm" show={@show_confirm} on_cancel="close_confirm">
  <:title>Confirm Action</:title>
  <:description>Are you sure?</:description>
  Content here
</.dialog>
<!-- Sets role="dialog", aria-modal, aria-labelledby, and aria-describedby automatically -->
<!-- Uses a div-based overlay for screen share compatibility -->

Tabs

<.tabs id="settings" default_value="account">
  <:tab value="account">Account</:tab>
  <:panel value="account">Account settings</:panel>
</.tabs>
<!-- Full tablist/tab/tabpanel ARIA pattern -->

Separator

<.separator />
<!-- Decorative by default and hidden from assistive technology -->

<.separator decorative={false} aria-label="Account settings" />
<!-- Use semantic mode when the divider communicates document structure -->

Marquee

<.marquee>
  <:item>New components released weekly</:item>
  <:item>24/7 support available</:item>
</.marquee>
<!-- Duplicated content is hidden from assistive tech and motion stops for reduced-motion users -->

Input OTP

<.input_otp id="mfa-code" name="code" groups={[3, 3]} />

Calendar

<.calendar selected={@date} />

Context Menu

<.context_menu id="message-menu">
  <:trigger>Right click</:trigger>
  <.context_menu_item>Reply</.context_menu_item>
</.context_menu>

AI Primitives

<.response id="answer" value={@streamed_answer} streaming reveal="word" />
<.response value={@streamed_markdown} format="markdown" streaming />
<.activity>
  <:item status="complete">Searched documentation</:item>
  <:item status="running">Drafting answer</:item>
</.activity>

Focus Management

Focus Trapping

Modal components manage focus according to their implementation:

  • dialog moves focus to the first focusable element when opened and traps focus with focus_wrap.
  • command_dialog uses the native <dialog> element and browser modal focus behavior.
  • If your workflow needs explicit focus restoration to a trigger, handle that in the parent LiveView or with app-side JavaScript.

Focus Indicators

Sutra-styled interactive components include visible focus indicators using the --ring CSS variable:

:root {
  --ring: oklch(0.705 0.015 286.067);  /* Focus ring color */
}

Focus indicators should be:

  • Visible when controls receive keyboard focus
  • High contrast against backgrounds
  • Consistent with the app theme

For page-level accessibility, add a skip link at the top of your layout:

<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:rounded">
  Skip to main content
</a>

<main id="main-content">
  <!-- Page content -->
</main>

Screen Reader Support

Live Regions

Toast notifications use aria-live to announce messages:

<.toast_container flash={@flash} />
<!-- Renders toast messages with status/live-region semantics -->

<.toast id="saved-toast">
  <:title>File saved successfully</:title>
</.toast>
<!-- Exposes role="status" and aria-live="polite" -->

Semantic HTML

Sutra UI uses semantic HTML elements:

  • <button> for buttons (not <div>)
  • <div role="dialog"> for modals (div-based for screen share compatibility)
  • <table> for data tables
  • <nav> for navigation
  • <form> for forms

Hidden Content

Use these utilities for screen reader content:

<!-- Visually hidden but accessible to screen readers -->
<span class="sr-only">Additional context</span>

<!-- Hidden from screen readers -->
<span aria-hidden="true">Decorative icon</span>

Testing Accessibility

  1. axe DevTools - Browser extension for automated testing
  2. WAVE - Web accessibility evaluation tool
  3. VoiceOver (macOS) / NVDA (Windows) - Screen reader testing
  4. Keyboard only - Navigate without a mouse

Testing Checklist

  • [ ] All interactive elements are focusable with Tab
  • [ ] Focus order is logical
  • [ ] Focus indicators are visible
  • [ ] All images have alt text
  • [ ] Form inputs have labels
  • [ ] Error states are exposed to assistive technology
  • [ ] Color is not the only means of conveying information
  • [ ] Text has sufficient contrast (4.5:1 for normal, 3:1 for large)

Common Accessibility Patterns

Icon Buttons

Always provide an aria-label. Sutra UI does not provide a general icon helper; these examples use inline SVG, but your application can use its own icon helper if one exists.

<!-- Good -->
<.button size="icon" aria-label="Delete item">
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</.button>

<!-- Bad - no accessible name -->
<.button size="icon">
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</.button>

Loading States

Use aria-busy and announce loading:

<.button loading aria-busy="true">
  <.spinner class="mr-2" />
  Loading...
</.button>

Disabled States

Use the disabled attribute, not just styling:

<!-- Good -->
<.button disabled>Cannot submit</.button>

<!-- Bad - looks disabled but isn't -->
<.button class="opacity-50 cursor-not-allowed">Cannot submit</.button>

Form Validation

Expose validation errors on inputs:

<.input
  field={@form[:password]}
  type="password"
  label="Password"
/>

With field={@form[...]}, <.input> reads errors from the Phoenix form field and only displays them after Phoenix.Component.used_input?/1 reports that the field was used. When the input has an id, generated helper and error text are linked through aria-describedby. For manual inputs, pass errors={...} directly.

Next Steps