DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

shadcn/ui Accessibility Audit: 14 Components That Fail WCAG 2.2 AA

If you are relying on shadcn/ui out of the box to pass a procurement review or accessibility audit, your app will not pass cleanly. While Radix UI handles the underlying keyboard and focus mechanics well, shadcn's default Tailwind classes and composition patterns break compliance on 14 out of 48 components.

The most common failure across the library is focus visibility. Radix gives you correct focus management, but shadcn's default styles use focus-visible:ring-1 ring-ring/50, which drops below the 3:1 non-text contrast ratio in most themes.

Here is the breakdown of what works, what fails, and how to patch the components in your codebase.

The 34 components that pass WCAG 2.2 AA

These pass WCAG 2.2 AA out of the box with the default install:

  • Layout and navigation: Accordion, Aspect Ratio, Breadcrumb, Card, Collapsible, Resizable, Scroll Area, Separator, Sheet, Sidebar.
  • Form primitives: Checkbox, Form, Input OTP, Label, Radio Group, Select, Switch, Textarea, Toggle, Toggle Group.
  • Feedback: Alert, Dialog, Popover, Progress, Skeleton, Sonner (Toast), Tooltip.
  • Display: Avatar, Badge, Calendar, Hover Card, Pagination, Table, Tabs.

These components inherit Radix UI's behavior without styling conflicts. Keep in mind that if you override --ring or --primary with low-contrast custom colors in your theme, you can still break these defaults.

9 components that need minor fixes

These components work mechanically, but their default configuration or styling fails specific criteria.

1. Button: focus ring contrast in default theme

The default Button uses focus-visible:ring-ring/50, which renders the focus indicator at 50% opacity. In light mode against a white background, this produces a contrast ratio of 2.4:1, below the WCAG AA threshold of 3:1 for non-text contrast.

In components/ui/button.tsx, update the focus classes:

// Before
"focus-visible:ring-ring/50 focus-visible:ring-[3px]"

// After
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
Enter fullscreen mode Exit fullscreen mode

This ensures a solid 2px ring at full opacity with an offset against the background color.

2. Input: placeholder contrast

The text-muted-foreground utility used for placeholder text falls below 4.5:1 in the default light theme (zinc-500 on white), failing WCAG 1.4.3.

Darken --muted-foreground in your theme, or override the placeholder color in components/ui/input.tsx:

"placeholder:text-zinc-600 dark:placeholder:text-zinc-400"
Enter fullscreen mode Exit fullscreen mode

Do not rely on the placeholder as the only label. Pair the input with an explicit label or aria-describedby pointing to visible helper text to satisfy WCAG 3.3.2.

3. Slider: keyboard step too coarse

Radix Slider supports arrow keys and Page Up/Down for navigation, but shadcn does not expose a step prop in the default wrapper. It defaults to 1, which works for 0 to 100 percentages but fails for precision numeric inputs.

When using the Slider, pass an explicit step prop:

<Slider value={[value]} onValueChange={...} min={0} max={100} step={1} />
Enter fullscreen mode Exit fullscreen mode

4. Command (Cmdk): missing aria-label on input

The cmdk primitive handles list navigation, but the shadcn wrapper does not provide a default aria-label on the search input. Screen readers announce the field as unlabelled "edit text".

In components/ui/command.tsx, add an accessible label to the input wrapper:

<CommandInput placeholder="Search..." aria-label="Search commands" />
Enter fullscreen mode Exit fullscreen mode

5. Carousel (Embla): autoplay ignores reduced motion

The shadcn Carousel autoplay example does not check for the user's motion preferences. WCAG 2.3.3 requires animations triggered by interaction or page loads to respect user settings.

Disable the plugin when prefers-reduced-motion is active:

import Autoplay from "embla-carousel-autoplay"

const plugins = useMemo(() => {
  if (typeof window === "undefined") return []
  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
  return reduceMotion ? [] : [Autoplay({ delay: 4000 })]
}, [])

<Carousel plugins={plugins}>...</Carousel>
Enter fullscreen mode Exit fullscreen mode

6. Toast (Sonner): duration too short

Sonner defaults to a 4-second auto-dismiss window. WCAG 2.2.1 requires users to have sufficient time to read and interact with content.

Increase the default duration and allow pauses on interaction:

<Toaster
  duration={8000}
  closeButton
  richColors
  pauseWhenPageIsHidden
/>
Enter fullscreen mode Exit fullscreen mode

For error states or critical alerts, set duration: Infinity so users must manually dismiss them.

7. Date Picker: month and year navigation touch targets

The Date Picker wraps Calendar inside a Popover. The month and year navigation buttons in the Calendar header use icons with small touch targets that fall short of WCAG 2.2 SC 2.5.8 (Target Size Minimum, 24x24px).

In components/ui/calendar.tsx, update the navigation button sizing:

// In components/ui/calendar.tsx, find the nav buttons and apply:
className="size-9 ..." // instead of size-7
aria-label="Previous month"
Enter fullscreen mode Exit fullscreen mode

8. Menubar: submenu trigger expanded states

In some custom builds, the shadcn Menubar wrapper can drop the aria-expanded state on trigger elements. Inspect your DOM output in browser devtools to verify that aria-expanded="true|false" updates when submenus open and close.

9. Drawer (Vaul): focus restoration

If the element that opened a Vaul drawer unmounts while the drawer is open (such as opening a drawer from inside a dropdown menu that closes immediately), focus will drop to the document body when the drawer closes. Keep the trigger mounted in the DOM, or pass an explicit focus ref to restore focus on close.

5 components with major accessibility gaps

These components contain structural issues that will be flagged in any standard accessibility audit.

1. Combobox: incomplete ARIA roles

The Combobox pattern in shadcn is not a standalone component. It is composed of a Popover and a Command menu. The standard example in the documentation misses three required combobox attributes:

  1. Missing aria-haspopup="listbox" on the trigger button.
  2. Missing aria-expanded state on the trigger button.
  3. Missing aria-controls pointing from the trigger to the list container.

Without these, screen readers treat the combobox as a standard button and do not announce state changes to the dropdown.

2. Data Table: missing captions, sort states, and live regions

The @tanstack/react-table implementation provided in the docs creates table markup without accessible headers or status regions:

  • The <table> element lacks a <caption>.
  • Pagination updates do not announce row ranges to screen readers.
  • Sortable column headers do not expose sort direction via aria-sort.
  • Selection checkboxes lack row-specific labels.

Patch your table implementation with these elements:

// Add caption
<Table>
  <caption className="sr-only">
    Customer list, {data.length} total customers
  </caption>
  ...
</Table>

// Add live region for pagination
<div aria-live="polite" className="sr-only">
  Showing rows {start} to {end} of {total}
</div>

// Sort state on column headers
<Button
  onClick={column.getToggleSortingHandler()}
  aria-label={`Sort by ${column.id}`}
  aria-sort={
    column.getIsSorted() === "asc" ? "ascending" :
    column.getIsSorted() === "desc" ? "descending" : "none"
  }
>
  {column.id}
</Button>

// Row selection labels
<Checkbox
  aria-label={`Select row for ${row.original.name}`}
  ...
/>
Enter fullscreen mode Exit fullscreen mode

3. Context Menu: non-functional shortcut hints

shadcn Context Menu displays visual keyboard shortcut hints (like "Cmd+C") by default, but does not attach actual keyboard listeners for those combinations. Under WCAG 3.2.4, presenting an explicit shortcut label that fails to execute creates an inconsistent interface. Either bind the shortcut with a key listener or remove the shortcut hint text.

4. Chart: missing non-text alternatives

The Chart component wraps Recharts SVGs. Recharts renders raw SVG paths without alternative text, leaving screen readers with an empty container. Under WCAG 1.1.1, charts require a text or tabular equivalent.

Pair the chart with an accessible, visually hidden table representing the dataset:

<ChartContainer>
  <BarChart data={data}>...</BarChart>
  <table className="sr-only">
    <caption>Monthly revenue, 2026</caption>
    <thead><tr><th>Month</th><th>Revenue</th></tr></thead>
    <tbody>
      {data.map(d => <tr key={d.month}><td>{d.month}</td><td>{d.revenue}</td></tr>)}
    </tbody>
  </table>
</ChartContainer>
Enter fullscreen mode Exit fullscreen mode

5. Input OTP: paste announcements

While the Input OTP component supports clipboard pasting, screen readers do not trigger individual input changes when a full code is pasted at once. Users relying on speech output get no confirmation that the code registered.

Add an aria-live status container next to the component to announce completion:

const [code, setCode] = useState("")
return (
  <>
    <InputOTP value={code} onChange={setCode} maxLength={6} />
    <div role="status" aria-live="polite" className="sr-only">
      {code.length === 6 ? "Verification code complete" : ""}
    </div>
  </>
)
Enter fullscreen mode Exit fullscreen mode

This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.

Top comments (0)