cli/assets/skills/ui-styling/references/shadcn-accessibility.md
ARIA patterns, keyboard navigation, screen reader support, and accessible component usage.
shadcn/ui built on Radix UI primitives - unstyled, accessible components following WAI-ARIA design patterns.
Benefits:
Focus visible states:
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Accessible Button
</Button>
Skip to content:
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2">
Skip to content
</a>
<main id="main-content">
</main>
Dialogs trap focus automatically via Radix Dialog primitive:
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<input />
<Button>Action</Button>
</DialogContent>
</Dialog>
Features:
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Keyboard shortcuts:
Space/Enter: Open menuArrow Up/Down: Navigate itemsEsc: Close menuTab: Close and move focusimport { Command } from "@/components/ui/command"
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search</CommandItem>
</CommandGroup>
</CommandList>
</Command>
Features:
Use proper HTML elements:
// Good: Semantic HTML
<button>Click me</button>
<nav><a href="/">Home</a></nav>
// Avoid: Div soup
<div onClick={handler}>Click me</div>
Label interactive elements:
<Button aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
<Input aria-label="Email address" type="email" />
Describe elements:
<Button aria-describedby="delete-description">
Delete Account
</Button>
<p id="delete-description" className="sr-only">
This action permanently deletes your account and cannot be undone
</p>
Use sr-only class for screen reader only content:
<Button>
<Trash className="h-4 w-4" />
<span className="sr-only">Delete item</span>
</Button>
// CSS for sr-only
.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;
}
Announce dynamic content:
<div aria-live="polite" aria-atomic="true">
{message}
</div>
// For urgent updates
<div aria-live="assertive">
{error}
</div>
Toast component includes live region:
const { toast } = useToast()
toast({
title: "Success",
description: "Profile updated"
})
// Announced to screen readers automatically
Always label inputs:
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" />
</div>
Add descriptions:
import { FormDescription, FormMessage } from "@/components/ui/form"
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name
</FormDescription>
<FormMessage />
</FormItem>
Announce errors to screen readers:
<FormField
control={form.control}
name="email"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
aria-invalid={!!fieldState.error}
aria-describedby={fieldState.error ? "email-error" : undefined}
/>
</FormControl>
<FormMessage id="email-error" />
</FormItem>
)}
/>
Indicate required fields:
<Label htmlFor="name">
Name <span className="text-destructive">*</span>
<span className="sr-only">(required)</span>
</Label>
<Input id="name" required />
Group related fields:
<fieldset>
<legend className="text-lg font-semibold mb-4">
Contact Information
</legend>
<div className="space-y-4">
<FormField name="email" />
<FormField name="phone" />
</div>
</fieldset>
import { Accordion } from "@/components/ui/accordion"
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>
Is it accessible?
</AccordionTrigger>
<AccordionContent>
Yes. Follows WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
</Accordion>
import { Tabs } from "@/components/ui/tabs"
<Tabs defaultValue="account">
<TabsList role="tablist">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
Account content
</TabsContent>
</Tabs>
import { Select } from "@/components/ui/select"
<Select>
<SelectTrigger aria-label="Choose theme">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
<div className="flex items-center space-x-2">
<Checkbox id="terms" aria-describedby="terms-description" />
<Label htmlFor="terms">Accept terms</Label>
</div>
<p id="terms-description" className="text-sm text-muted-foreground">
You agree to our Terms of Service and Privacy Policy
</p>
import { Alert } from "@/components/ui/alert"
<Alert role="alert">
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Your session has expired
</AlertDescription>
</Alert>
Ensure sufficient contrast between text and background.
WCAG Requirements:
Check defaults:
// Good: High contrast
<p className="text-gray-900 dark:text-gray-100">Text</p>
// Avoid: Low contrast
<p className="text-gray-400 dark:text-gray-600">Hard to read</p>
Muted text:
// Use semantic muted foreground
<p className="text-muted-foreground">
Secondary text with accessible contrast
</p>
Always provide visible focus indicators:
Default focus ring:
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Button
</Button>
Custom focus styles:
<a href="#" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:underline">
Link
</a>
Don't remove focus styles:
// Avoid
<button className="focus:outline-none">Bad</button>
// Use focus-visible instead
<button className="focus-visible:ring-2">Good</button>
Respect reduced motion preference:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In components:
<div className="transition-all motion-reduce:transition-none">
Respects user preference
</div>
Testing tools:
Automated testing:
npm install -D @axe-core/react
import { useEffect } from 'react'
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000)
})
}