Apply this skill when building, reviewing, or refactoring any UI in a React + TypeScript + Tailwind + shadcn/ui application. Triggers on requests like "add a form", "build a modal", "create a dropdown", "make this accessible", "add a table", "build a navigation", or any time you are creating interactive components. Use proactively — every component must be accessible by default. EU accessibility law (EAA) mandates compliance since June 2025.
84
Quality
84%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Accessibility is not a feature — it's a baseline. Every interactive element must be keyboard-navigable, screen-reader-announced, and visually distinguishable. WCAG 2.2 AA is the minimum standard.
Use the correct HTML element before reaching for ARIA. Semantic elements have built-in keyboard handling, focus management, and screen reader announcements.
// ❌ Div with click handler — not focusable, not keyboard-accessible, no role
<div onClick={handleClick}>Save</div>
// ✅ Button — focusable, Enter/Space activation, announced as "button"
<button onClick={handleClick}>Save</button>| Need | Use | Not |
|---|---|---|
| Clickable action | <button> | <div onClick> |
| Navigation link | <a href> or <Link> | <span onClick> |
| Page region | <main>, <nav>, <aside>, <header>, <footer> | <div> |
| List of items | <ul> / <ol> + <li> | Nested <div>s |
| Data grid | <table> with <thead>, <th scope> | CSS grid of divs |
| Heading hierarchy | <h1> through <h6> in order | Styled <p> or <div> |
// Tailwind provides focus-visible by default — never remove it
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Save
</Button>
// ❌ Never suppress focus indicators
<button className="outline-none focus:outline-none">Save</button>When content appears (modals, dialogs, drawers), move focus into it. When it closes, return focus to the trigger.
// shadcn Dialog handles this automatically — use it instead of building custom modals
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'Dashboard apps with sidebars need a skip link so keyboard users can bypass navigation.
// In your root layout
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background"
>
Skip to content
</a>
// ...
<main id="main-content">Every input needs a label. shadcn FormLabel handles this — don't skip it.
// ❌ No label — screen reader announces "edit text" with no context
<Input value={name} onChange={...} />
// ✅ Linked label via FormField
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
// ✅ For icon-only buttons, use aria-label
<Button variant="ghost" size="icon" aria-label="Delete user">
<Trash2 className="h-4 w-4" />
</Button>When content changes without user action (toasts, status updates, live counts), announce it:
// Toast notifications — shadcn's Toaster uses aria-live internally
// For custom status updates:
<div role="status" aria-live="polite">
{isLoading ? 'Loading...' : `${count} results found`}
</div><table>
<caption className="sr-only">List of candidates</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
<th scope="col">Stage</th>
</tr>
</thead>
<tbody>
{candidates.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td>{c.role}</td>
<td>{c.stage}</td>
</tr>
))}
</tbody>
</table>dnd-kit supports accessibility — configure it:
const announcements = {
onDragStart: ({ active }) => `Picked up ${active.data.current?.name}`,
onDragOver: ({ active, over }) => over
? `${active.data.current?.name} is over ${over.data.current?.name}`
: `${active.data.current?.name} is no longer over a droppable area`,
onDragEnd: ({ active, over }) => over
? `${active.data.current?.name} was dropped on ${over.data.current?.name}`
: `${active.data.current?.name} was dropped`,
onDragCancel: ({ active }) => `Dragging was cancelled. ${active.data.current?.name} was dropped`,
}
<DndContext accessibility={{ announcements }}>// ❌ Status conveyed only by colour — invisible to colour-blind users
<div className={cn(status === 'error' ? 'text-red-500' : 'text-green-500')}>
{status}
</div>
// ✅ Colour + icon + text
<div className={cn('flex items-center gap-1', status === 'error' ? 'text-destructive' : 'text-green-600')}>
{status === 'error' ? <AlertCircle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
{status === 'error' ? 'Failed' : 'Success'}
</div>shadcn's CSS variables (--foreground, --muted-foreground, etc.) are designed for contrast compliance. Don't override with raw hex values.
// Informative images — descriptive alt text
<img src={chart} alt="Revenue increased 23% from Q1 to Q2 2026" />
// Decorative images — empty alt
<img src={decoration} alt="" />
// Icons next to text — hide from screen readers
<Button>
<Trash2 className="h-4 w-4" aria-hidden="true" />
Delete
</Button>Add eslint-plugin-jsx-a11y to catch accessibility issues at lint time:
pnpm add -D eslint-plugin-jsx-a11yThis catches: missing alt text, missing labels, invalid ARIA attributes, non-interactive elements with handlers, and more.
jest-axe or @axe-core/react catches ~30% of accessibility issues