Best practices for building accessible interfaces
Tags: pattern accessibility a11y
This guide covers accessibility best practices and patterns for building inclusive applications.
Core Principles
- Perceivable - Information must be presentable in ways users can perceive
- Operable - Interface must be operable by all users
- Understandable - Information and operation must be understandable
- Robust - Content must be robust enough for diverse user agents
Keyboard Navigation
Focus Management
All interactive elements should be focusable and have visible focus indicators:
<button
class="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Click me
</button>Skip Links
Use AppShell's built-in skip link or add your own:
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground"
>
Skip to main content
</a>
<main id="main-content">
<!-- Content -->
</main>Logical Tab Order
Ensure tab order follows visual order:
<!-- Good: natural reading order -->
<form>
<Input label="First Name" />
<Input label="Last Name" />
<Input label="Email" />
<Button type="submit">Submit</Button>
</form>
<!-- Avoid: using tabindex to reorder -->
<form>
<Input label="Email" tabindex="3" />
<Input label="First Name" tabindex="1" />
<Input label="Last Name" tabindex="2" />
</form>Focus Trapping
Modal dialogs should trap focus:
<!-- Dialog and AlertDialog components handle this automatically -->
<Dialog open={showModal}>
<Dialog.Content>
<!-- Focus is trapped within -->
<Input label="Name" />
<Button>Save</Button>
<Button variant="outline">Cancel</Button>
</Dialog.Content>
</Dialog>Screen Readers
Semantic HTML
Use proper HTML elements:
<!-- Good: semantic elements -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<main>
<article>
<h1>Page Title</h1>
<p>Content...</p>
</article>
</main>
<footer>
<p>Copyright 2024</p>
</footer>ARIA Labels
Provide context for screen readers:
<!-- Icon buttons need labels -->
<Button variant="ghost" size="icon" aria-label="Close dialog">
<XIcon />
</Button>
<!-- Describe complex regions -->
<section aria-labelledby="stats-heading">
<h2 id="stats-heading" class="sr-only">Dashboard Statistics</h2>
<!-- Stats cards -->
</section>
<!-- Indicate current state -->
<a href="/dashboard" aria-current="page">Dashboard</a>Live Regions
Announce dynamic content:
<!-- Toast notifications use aria-live -->
<div role="status" aria-live="polite">
{#each toasts as toast}
<div>{toast.message}</div>
{/each}
</div>
<!-- For important alerts -->
<div role="alert" aria-live="assertive">
{#if error}
<p>{error}</p>
{/if}
</div>Hidden Content
Hide decorative or redundant content:
<!-- Decorative icons -->
<svg aria-hidden="true">...</svg>
<!-- Redundant text -->
<a href="/read-more">
Read more
<span class="sr-only">about our services</span>
</a>
<!-- Screen reader only text -->
<span class="sr-only">Loading, please wait</span>Form Accessibility
Labels
Always associate labels with inputs:
<!-- Using FormField component -->
<FormField id="email" label="Email Address" required>
<Input id="email" type="email" />
</FormField>
<!-- Manual association -->
<label for="password">Password</label>
<input id="password" type="password" />Error Messages
Link errors to inputs:
<Input id="email" aria-describedby={error ? 'email-error' : undefined} aria-invalid={!!error} />
{#if error}
<p id="email-error" class="text-sm text-destructive">
{error}
</p>
{/if}Required Fields
Indicate required fields clearly:
<FormField label="Email" required description="We'll never share your email">
<Input type="email" required aria-required="true" />
</FormField>Fieldsets
Group related fields:
<fieldset>
<legend class="font-semibold mb-2">Contact Preferences</legend>
<Checkbox label="Email" />
<Checkbox label="Phone" />
<Checkbox label="SMS" />
</fieldset>Color and Contrast
Color Contrast
Ensure sufficient contrast ratios:
- Normal text: 4.5:1 minimum
- Large text: 3:1 minimum
- UI components: 3:1 minimum
<!-- Theme colors are designed for contrast -->
<p class="text-foreground bg-background">High contrast text</p>
<p class="text-muted-foreground">Muted but still accessible</p>Color Independence
Don't rely on color alone:
<!-- Good: icon + color -->
<Alert variant="error">
<AlertCircleIcon class="h-4 w-4" />
Error message
</Alert>
<!-- Good: text + color -->
<Badge variant="success">Active ✓</Badge>
<!-- Avoid: color only -->
<span class="text-red-500">Required</span>Interactive Components
Buttons vs Links
Use the right element:
<!-- Button: performs an action -->
<Button onclick={handleSave}>Save</Button>
<!-- Link: navigates somewhere -->
<a href="/settings">Settings</a>
<!-- Button styled as link (still performs action) -->
<Button variant="link" onclick={handleAction}>Do something</Button>Disabled States
Provide clear disabled states:
<Button disabled aria-disabled="true">Processing...</Button>
<Input disabled aria-disabled="true" />Loading States
Indicate loading to all users:
<Button disabled aria-busy="true">
<Spinner class="mr-2" aria-hidden="true" />
<span>Loading...</span>
</Button>Motion and Animation
Reduce Motion
Respect user preferences:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Theme components use Tailwind's motion-reduce: variant:
<div class="transition-all duration-300 motion-reduce:transition-none">Animated content</div>Testing Checklist
- Can navigate with keyboard only
- Focus is visible on all interactive elements
- Screen reader announces content correctly
- Color contrast meets WCAG AA
- Information isn't conveyed by color alone
- Forms have proper labels and error handling
- Modals trap focus and can be dismissed
- Skip links work correctly
- Animations can be disabled
- Touch targets are at least 44x44px
Resources
- WCAG 2.1 Guidelines (opens in new tab)
- MDN Accessibility Guide (opens in new tab)
- Inclusive Components (opens in new tab)