shadcn/ui and Radix: How to Maintain Accessibility When Customizing Components
Last week, a colleague asked me: “Why can’t I click this button with my keyboard?”
I froze for a second. We’re using shadcn/ui—how could this happen? I opened DevTools and found he’d wrapped a <div> around Tooltip.Trigger to add custom styling. That was the problem.
Honestly, I’ve made similar mistakes. When I started with shadcn/ui, I thought these components were “free to modify”—after all, the code is copied right into your project. Change some styles, swap a tag, add a wrapper… seemed harmless. Until QA found keyboard navigation broken, screen readers couldn’t read the content, and the whole interaction flow fell apart.
That’s when I realized: shadcn/ui’s freedom comes at a price. You get the source code, but there’s Radix accessibility magic hidden underneath. Mess with it, and the spell breaks.
This article explores the relationship between shadcn/ui and Radix, focusing on how to preserve accessibility when customizing components. By the end, you’ll understand asChild usage, focus management, and ARIA inheritance—enough to know what you can and can’t touch.
shadcn/ui and Radix: What’s the Relationship?
First, something many people don’t realize: shadcn/ui isn’t an npm package.
You can’t npm install @shadcn/ui. It’s essentially a “code distribution platform”—you get component source code, copy it into your project, and it’s entirely yours. Modify it, delete it, no one’s stopping you.
So where does the accessibility come from? Radix.
Radix UI is an “unstyled component library,” also called Primitives. It doesn’t give you styles—it gives you behavior. How Dialog manages focus when opened, how Dropdown Menu handles arrow keys, how Tooltip hides from screen readers. All this follows WAI-ARIA specs and has been tested with NVDA, JAWS, and VoiceOver.
shadcn/ui is just Radix with a layer of Tailwind CSS styling. You get the looks, Radix’s accessibility behavior hides underneath. You copy a button component, see some Tailwind classes, but Radix logic is wrapped inside.
To put it simply:
- Radix handles “it works”: aria attributes, role, focus management, keyboard navigation
- shadcn/ui handles “it looks good”: Tailwind styles, design consistency
So when modifying shadcn/ui components, remember: you’re changing the “surface layer,” but the “foundation” behavior comes from Radix. Surface is fair game. Foundation breaks things.
The asChild Prop: Magic or Trap?
asChild is a special prop in Radix. Most Radix component parts support it.
What does it do? Take Tooltip.Trigger—by default it renders a <button> element. But what if you want to add a tooltip to a link? That’s where asChild comes in:
<Tooltip.Trigger asChild>
<a href="/help">Help Center</a>
</Tooltip.Trigger>
When asChild={true}, Radix doesn’t render its own <button>. Instead, it “clones” your child element and passes its behavior and props to it. Now this link has all Tooltip Trigger functionality: hover to show tooltip, keyboard focus triggers it, correct aria attributes.
Seems convenient.
But here’s the trap.
If you switch to a non-focusable element, accessibility is gone.
// ❌ Wrong
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">Click me</div>
</Tooltip.Trigger>
A div can’t receive keyboard focus (unless you add tabIndex={0}), doesn’t respond to Enter/Space keys. Screen readers won’t treat it as a button. Keyboard users can’t “reach” this tooltip at all.
Radix docs state clearly: “If you were to switch it to a div, it would no longer be accessible.”
That said, you rarely swap directly to div. More commonly, you use your own React component:
<Tooltip.Trigger asChild>
<MyButton>Click me</MyButton>
</Tooltip.Trigger>
This works fine, but there are two rules you must follow:
1. Your component must spread props
Radix passes a bunch of props when cloning: event handlers, aria attributes, ref. If your component doesn’t accept them, functionality breaks.
// ❌ Wrong: doesn't accept props
const MyButton = () => <button className="btn">...</button>
// ✅ Correct: spread all props
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. Your component must forward ref
Radix sometimes needs direct DOM access (measuring size, managing focus). Without ref, it errors.
// ❌ Wrong: doesn't accept ref
const MyButton = (props) => <button {...props}>...</button>
// ✅ Correct: forward ref
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
Honestly, these rules apply to any “leaf component,” not just Radix. Accepting all props and ref is basic hygiene.
Here’s something fun: multiple Radix components can be nested.
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>Open dialog</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
One button, simultaneously a Tooltip Trigger and Dialog Trigger. Both behaviors stacked together, no problem.
Focus Management and Keyboard Navigation
Focus management is the most overlooked part of accessibility.
Many people focus on “looks good” and forget users might not use a mouse. Keyboard users, screen reader users—they depend entirely on focus position.
Radix handles much of this automatically. Example:
When AlertDialog opens, focus moves to the Cancel button.
This is deliberate design. AlertDialog usually confirms dangerous actions (delete, exit). After opening, the most likely action is “cancel” not “confirm.” Focus on Cancel means one Enter press closes the dialog, preventing accidents.
What if focus landed on Confirm? User accidentally hits Enter, deletion happens. Disaster.
This behavior follows WAI-ARIA authoring practices. You don’t write the code yourself.
But here’s the issue: if you customize AlertDialog content, focus might go wrong.
Say you add an input field:
<AlertDialog.Content>
<AlertDialog.Title>Confirm delete?</AlertDialog.Title>
<AlertDialog.Description>Type "DELETE" to confirm</AlertDialog.Description>
<input placeholder="Type DELETE" /> {/* You added this */}
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>Confirm</AlertDialog.Action>
</AlertDialog.Content>
Now when the dialog opens, where does focus go?
Radix defaults to the first focusable element. Your input comes before Cancel, so focus lands in the input. User has to Tab several times to reach Cancel. The expected flow is broken.
Solution: use autoFocus to specify the focus target, or reorder elements.
<AlertDialog.Content>
<AlertDialog.Title>Confirm delete?</AlertDialog.Title>
<AlertDialog.Description>Type "DELETE" to confirm</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>Cancel</AlertDialog.Cancel> {/* Force focus */}
<input placeholder="Type DELETE" />
<AlertDialog.Action>Confirm</AlertDialog.Action>
</AlertDialog.Content>
Move Cancel to the front, or add autoFocus. Now focus won’t wander.
Keyboard navigation has similar issues.
Tabs component: users switch tabs with left/right arrows—standard WAI-ARIA behavior. If you add custom styles and accidentally override role="tab", keyboard navigation breaks.
Dropdown Menu: up/down arrows select items, Enter confirms, Esc closes. Radix handles all this internally. But if you use onClick instead of onSelect for menu items, you might break keyboard behavior.
Testing method is simple: throw away the mouse, navigate the entire component flow with keyboard only.
- Can Tab enter the component?
- Can arrow keys switch options?
- Can Enter trigger actions?
- Can Esc close dialogs?
If any step gets stuck, you have an accessibility problem.
ARIA Attribute Auto-Inheritance
Radix saves you effort with ARIA attributes.
It automatically adds correct role and aria-* attributes. Examples:
- Dialog gets
role="dialog"andaria-modal="true" - Tabs.Tab gets
role="tab"andaria-selected - Switch gets
role="switch"andaria-checked"
You don’t manage these. Radix handles them internally.
But there’s one thing you must do: provide an accessible name for controls.
Screen reader users need to know what this button does, what this dialog is called, what goes in this input field. Without names, they’re guessing.
Radix provides a Label primitive:
<Label.Root htmlFor="email-input">Email Address</Label.Root>
<Input id="email-input" />
Label.Root automatically links to the input. Screen readers announce “Email Address” before reading the input value.
For custom controls (not native inputs), provide names manually.
<Switch aria-label="Enable dark mode" />
<Tabs.Tab aria-label="Product details" />
Or use aria-labelledby to link to visible text:
<div id="mode-label">Dark mode</div>
<Switch aria-labelledby="mode-label" />
Verification: open a screen reader and test.
Mac has VoiceOver (Cmd+F5 to start), Windows has NVDA (free download). Listen to how your components are read. If you hear “button” instead of “Submit order button,” you’re missing an accessible name.
One more thing: color contrast.
Radix doesn’t handle styles, so color contrast is your responsibility. WCAG requires at least 4.5:1 contrast ratio for normal text, 3:1 for large text. shadcn/ui defaults usually pass, but be careful when changing colors.
There’s a tool called WebAIM Contrast Checker—enter foreground and background colors to calculate contrast.
Practical Checklist
After customizing any shadcn/ui component, verify with this checklist:
asChild Checks
- Is the
asChildchild element focusable? (button/a/input, not div) - Did your custom component spread all props?
- Did your custom component forward ref?
Focus Management Checks
- When dialog opens, does focus land correctly?
- When dialog closes, does focus return to the trigger?
- With nested focusable elements, is focus order logical?
Keyboard Navigation Checks
- Can Tab enter the component?
- Can arrow keys switch options (Tabs, Dropdown)?
- Can Enter trigger actions?
- Can Esc close dialogs?
- Can Space toggle states (Switch, Checkbox)?
ARIA Checks
- Does every control have an accessible name?
- Can screen readers correctly announce roles and states?
- Do dynamic state changes have correct aria-live regions?
Visual Checks
- Is the focus indicator clearly visible?
- Does color contrast meet standards (4.5:1 or 3:1)?
- Is information conveyed through more than just color (icons or text too)?
Testing Tools
- Keyboard test: Disconnect mouse, navigate entire flow with keyboard only
- Screen reader: VoiceOver (Mac) or NVDA (Windows)
- Automated: axe DevTools browser extension
Conclusion
shadcn/ui gives you code freedom, but that freedom has boundaries.
The boundary is Radix’s accessibility behavior. You can change styles, layouts, class names—but don’t break the underlying behavior logic. Swap button for div, forget to spread props, and keyboard users suffer.
Remember these points:
- With asChild: child must be focusable, custom components need spread props + forward ref
- Focus management: when customizing dialog content, check where focus lands
- ARIA attributes: Radix adds role automatically, but you provide the label
Next time you modify a component, test with keyboard first. Find issues, fix them immediately. Don’t wait for QA.
In the end, accessibility isn’t an “extra feature”—it’s a baseline requirement. shadcn/ui and Radix already did the hardest parts. Just don’t undo their good work.
FAQ
What's the relationship between shadcn/ui and Radix UI?
How do I use asChild without breaking accessibility?
• Child element must be focusable (button/a/input), cannot use div
• Custom component must spread props: `(props) => <button {...props} />`
• Custom component must forward ref: `React.forwardRef((props, ref) => ...)`
What should I watch for with focus management when customizing dialogs?
How do I test if component accessibility works correctly?
• Can Tab enter the component?
• Can arrow keys switch options?
• Can Enter trigger actions?
• Can Esc close dialogs?
Then open a screen reader (VoiceOver on Mac, NVDA on Windows) and listen through once.
If Radix adds aria attributes automatically, what do I still need to do?
9 min read · Published on: Mar 30, 2026 · Modified on: Mar 30, 2026
Related Posts
Tailwind Performance Optimization: JIT, Content Configuration, and Production Bundle Size Control
Tailwind Performance Optimization: JIT, Content Configuration, and Production Bundle Size Control
Nginx Reverse Proxy Complete Guide: Upstream, Buffering, and Timeout
Nginx Reverse Proxy Complete Guide: Upstream, Buffering, and Timeout
Dialog, Sheet, Popover: Accessibility and Focus Management for Overlay Components

Comments
Sign in with GitHub to leave a comment