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.

84

Quality

84%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Skills
Evals
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

Install with Tessl CLI

npx tessl i product-factory/accessibility@0.1.0

SKILL.md

tile.json