CtrlK
BlogDocsLog inGet started
Tessl Logo

product-factory/accessibility

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

Quality

84%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

SKILL.md

name:
accessibility
description:
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.

Accessibility: React + TypeScript + Tailwind + shadcn/ui

The Core Rule

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.


Semantic HTML First

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>
NeedUseNot
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 orderStyled <p> or <div>

Keyboard Navigation

All Interactive Elements Must Be Keyboard-Accessible

  • Tab moves focus between interactive elements
  • Enter/Space activates buttons and links
  • Escape closes modals, popovers, dropdowns
  • Arrow keys navigate within composite widgets (tabs, menus, listboxes)

Focus Must Be Visible

// 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>

Focus Management for Dynamic Content

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'

Skip-to-Content Link

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">

ARIA — When Semantic HTML Isn't Enough

Labels for Form Inputs

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>

Live Regions for Dynamic Updates

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>

Tables

<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>

Drag and Drop

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 }}>

Colour and Contrast

Minimum Contrast Ratios (WCAG AA)

  • Normal text (< 18px): 4.5:1 against background
  • Large text (>= 18px bold or >= 24px): 3:1
  • UI components and graphical objects: 3:1

Never Rely on Colour Alone

// ❌ 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>

Use Tailwind Design Tokens

shadcn's CSS variables (--foreground, --muted-foreground, etc.) are designed for contrast compliance. Don't override with raw hex values.


Images and Media

// 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>

Linting

Add eslint-plugin-jsx-a11y to catch accessibility issues at lint time:

pnpm add -D eslint-plugin-jsx-a11y

This catches: missing alt text, missing labels, invalid ARIA attributes, non-interactive elements with handlers, and more.


Testing

  • Automated: jest-axe or @axe-core/react catches ~30% of accessibility issues
  • Keyboard: Tab through every interactive flow manually
  • Screen reader: Test with VoiceOver (macOS) or NVDA (Windows)
  • Contrast: Use browser DevTools accessibility inspector

SKILL.md

tile.json