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.
67
84%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
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