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"
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"
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} />
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" />
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>
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
/>
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"
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:
- Missing
aria-haspopup="listbox"on the trigger button. - Missing
aria-expandedstate on the trigger button. - Missing
aria-controlspointing 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}`}
...
/>
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>
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>
</>
)
This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.
Top comments (0)