Claude Code with Radix UI: Unstyled Primitives, Real Accessibility
Why Radix UI without CLAUDE.md ships broken accessibility
Radix UI is a set of unstyled, accessible component primitives for React. Where Chakra ships pre-styled components and a theme system, Radix ships the focus management, keyboard navigation, ARIA wiring, and state machines without any visual opinion. You bring your own styling, usually Tailwind or CSS modules. This is the same foundation shadcn/ui copies into your project, and it is the dominant accessibility primitive set in the 2026 React ecosystem.
The accessibility contract Radix maintains is intricate. A Radix Dialog handles: focus trap inside the dialog, restoration of focus on close, escape-key closing, click-outside closing (with overlay), preventing body scroll, ARIA role and labelling, screen reader announcements. A Radix Menu handles: arrow-key navigation, type-ahead search, focus on first item on open, Home/End key support, ARIA role="menu" wiring. These behaviours are wired by composition. Break the composition and the accessibility breaks silently.
By default, Claude Code generates Radix code that compiles and renders but breaks the composition in subtle ways. The most common patterns: replacing asChild with manual prop spreading (which loses ref forwarding and event handler composition), conditional rendering of primitives outside their Root context (which kills the state machine), portal targets that mismatch the dialog overlay (which puts the dialog behind other content), and mixing controlled and uncontrolled state on the same primitive (which causes warnings and inconsistent behaviour).
This guide covers the CLAUDE.md configuration that locks Claude Code into Radix's composition model: asChild for ref composition, controlled state with explicit handlers, portal management, focus trap respect, and the patterns that block the most common Radix mistakes. For the React composition layer Radix sits on top of, Claude Code with React covers ref forwarding and component composition. If you are using shadcn/ui's Radix wrapper, Claude Code with shadcn/ui covers the cn() helper and Tailwind patterns.
The Radix UI CLAUDE.md template
# Radix UI rules
## Stack
- @radix-ui/react-* primitives (Dialog, Dropdown, Popover, Tabs, etc.)
- Tailwind CSS for styling (or your project's CSS solution)
- React 18.x with Next.js 14.x App Router or Vite
- Optional: @radix-ui/themes for pre-styled wrapper components
- Optional: @radix-ui/colors for the color palette
## Project structure
- src/components/ui/ Reusable Radix-composed components (Button, Dialog, Menu)
- src/components/ Project-specific compositions using ui/ building blocks
## Radix composition rules
- ALWAYS use asChild when wrapping a child component:
<Dialog.Trigger asChild>
<button>Open</button>
</Dialog.Trigger>
- NEVER spread {...props} from Radix into a child manually, asChild does this
- ALL Radix primitives are SINGLE-CHILD only when using asChild
- Conditional content inside primitives: render the inner element conditionally,
NEVER conditionally render the Radix primitive itself
## Controlled vs uncontrolled state
- Pick ONE per primitive instance, NEVER mix
- Uncontrolled: defaultOpen, defaultValue (Radix manages state)
- Controlled: open + onOpenChange, value + onValueChange (you manage state)
- Mixing causes React warnings AND silent state desync
## Portal rendering
- Radix Dialog and Popover use Portal by default for z-index correctness
- The portal target is document.body unless specified
- For modals over Next.js layout: ensure no parent has CSS transform/filter
(creates a new stacking context that breaks portal layering)
## Hard rules
- NEVER manually spread {...props} from Radix, ALWAYS use asChild
- NEVER conditionally render the primitive Root (use open prop instead)
- NEVER mix controlled and uncontrolled state on one primitive
- NEVER remove the Radix focus trap by adding tabIndex={-1} on Dialog content
- NEVER skip the visually hidden title (Dialog requires <Dialog.Title>)
- ALWAYS use Portal for Dialog and Popover to avoid z-index battles
The asChild rule is the most important Radix concept Claude gets wrong. Radix primitives need to attach event handlers, refs, and data attributes to a DOM element. By default they render their own element. With asChild, they merge their props into the single child you pass. The pattern looks like this:
// Default: Radix renders its own button
<Dialog.Trigger>Open dialog</Dialog.Trigger>
// asChild: Radix merges props into your custom Button
<Dialog.Trigger asChild>
<CustomButton variant="primary">Open dialog</CustomButton>
</Dialog.Trigger>
Without asChild, Claude tends to either nest a button inside a button (which is invalid HTML) or spread Radix's props manually to its own element (which loses ref forwarding and breaks the composition). The CLAUDE.md rule forces the correct pattern.
The controlled vs uncontrolled rule prevents a subtle React warning. If you pass both open={value} and defaultOpen={true} to a Dialog, React warns about controlled/uncontrolled switching and the state may desync. Pick one form and stick with it.
Install and Dialog setup
Install the primitives you need. Each primitive is its own package:
npm i @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-popover
Build a reusable Dialog component that wraps the primitive with your styling:
// src/components/ui/dialog.tsx
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { ReactNode, forwardRef } from 'react';
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className = '', ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={`fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out ${className}`}
{...props}
/>
));
DialogOverlay.displayName = 'DialogOverlay';
export const DialogContent = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className = '', children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={`fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-white p-6 shadow-lg rounded-lg ${className}`}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = 'DialogContent';
export const DialogTitle = DialogPrimitive.Title;
export const DialogDescription = DialogPrimitive.Description;
Use the Dialog:
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogTrigger } from '@/components/ui/dialog';
function DeleteButton({ itemId }: { itemId: string }) {
return (
<Dialog>
<DialogTrigger asChild>
<button className="rounded bg-red-600 px-4 py-2 text-white">
Delete item
</button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. The item will be permanently deleted.
</DialogDescription>
<div className="flex justify-end gap-2">
<DialogTrigger asChild>
<button className="rounded border px-4 py-2">Cancel</button>
</DialogTrigger>
<button
onClick={() => handleDelete(itemId)}
className="rounded bg-red-600 px-4 py-2 text-white"
>
Yes, delete
</button>
</div>
</DialogContent>
</Dialog>
);
}
Notice asChild on both DialogTrigger usages. The first wraps your custom Delete button. The second wraps the Cancel button so closing the dialog uses the same primitive without nesting buttons.
The hidden <span className="sr-only">Close</span> inside the X button is the screen-reader-only label that Radix relies on. Without it, the close button is announced as "button" with no name. Claude omits this consistently without the CLAUDE.md rule.
Add Dialog patterns to CLAUDE.md:
## Dialog composition
- DialogTrigger asChild + your styled button
- DialogContent wraps Portal + Overlay automatically
- ALWAYS include DialogTitle (Radix requires it for ARIA)
- DialogDescription is optional but recommended for screen readers
- Close button needs visible icon + sr-only text: <X /><span className="sr-only">Close</span>
- Cancel buttons: use DialogTrigger asChild to inherit close behaviour
- NEVER nest buttons: <button><button>X</button></button> is invalid HTML
Dropdown menu composition
The Dropdown primitive handles arrow-key navigation, type-ahead search, focus on first item on open, and proper ARIA wiring. Use it for any menu that appears on click.
// src/components/ui/dropdown.tsx
'use client';
import * as DropdownPrimitive from '@radix-ui/react-dropdown-menu';
import { forwardRef } from 'react';
export const Dropdown = DropdownPrimitive.Root;
export const DropdownTrigger = DropdownPrimitive.Trigger;
export const DropdownPortal = DropdownPrimitive.Portal;
export const DropdownContent = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DropdownPrimitive.Content>
>(({ className = '', sideOffset = 4, ...props }, ref) => (
<DropdownPortal>
<DropdownPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`z-50 min-w-32 overflow-hidden rounded-md border bg-white p-1 shadow-md ${className}`}
{...props}
/>
</DropdownPortal>
));
DropdownContent.displayName = 'DropdownContent';
export const DropdownItem = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DropdownPrimitive.Item>
>(({ className = '', ...props }, ref) => (
<DropdownPrimitive.Item
ref={ref}
className={`flex cursor-pointer items-center px-3 py-2 text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${className}`}
{...props}
/>
));
DropdownItem.displayName = 'DropdownItem';
export const DropdownSeparator = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DropdownPrimitive.Separator>
>(({ className = '', ...props }, ref) => (
<DropdownPrimitive.Separator
ref={ref}
className={`my-1 h-px bg-gray-200 ${className}`}
{...props}
/>
));
DropdownSeparator.displayName = 'DropdownSeparator';
Use the dropdown:
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator } from '@/components/ui/dropdown';
function AccountMenu() {
return (
<Dropdown>
<DropdownTrigger asChild>
<button className="rounded bg-gray-200 px-3 py-2">
Account
</button>
</DropdownTrigger>
<DropdownContent>
<DropdownItem onSelect={() => router.push('/profile')}>
Profile
</DropdownItem>
<DropdownItem onSelect={() => router.push('/settings')}>
Settings
</DropdownItem>
<DropdownSeparator />
<DropdownItem onSelect={handleLogout} className="text-red-600">
Log out
</DropdownItem>
</DropdownContent>
</Dropdown>
);
}
onSelect fires when the user clicks the item or presses Enter while it has keyboard focus. Use this instead of onClick to get keyboard support for free.
Add Dropdown patterns to CLAUDE.md:
## Dropdown composition
- DropdownTrigger asChild + your styled button
- DropdownItem onSelect for actions (keyboard + click)
- NEVER use onClick on DropdownItem (loses keyboard support)
- DropdownSeparator between logical groups
- Destructive items: red text + warning verbiage
- Arrow-key navigation, type-ahead search, Home/End all wired by Radix
Controlled state pattern
When you need to drive a Radix primitive from external state (form context, URL state, server-fetched value), use the controlled pattern:
import { useState } from 'react';
import { Dialog, DialogContent, DialogTrigger, DialogTitle } from '@/components/ui/dialog';
function ControlledDialog() {
const [open, setOpen] = useState(false);
// Open from external trigger
useEffect(() => {
if (someExternalCondition) {
setOpen(true);
}
}, [someExternalCondition]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Controlled dialog</DialogTitle>
<p>Closing this updates external state.</p>
</DialogContent>
</Dialog>
);
}
The pair open={open} onOpenChange={setOpen} is the controlled form. Removing either half breaks the pattern. Adding defaultOpen={true} alongside causes React to warn about controlled/uncontrolled switching.
The uncontrolled form:
<Dialog defaultOpen={false}>
<DialogTrigger asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Uncontrolled dialog</DialogTitle>
</DialogContent>
</Dialog>
Radix manages all state internally. You cannot externally close the dialog or detect when it opens. Use uncontrolled for simple cases where the trigger button is the only way to open and close.
Add the controlled rule to CLAUDE.md:
## Controlled vs uncontrolled
Controlled (pick this when external state matters):
- open + onOpenChange
- value + onValueChange (for Select, Tabs)
Uncontrolled (pick this for simple in-place primitives):
- defaultOpen + no onOpenChange
- defaultValue + no onValueChange
NEVER mix in one primitive instance:
- open={true} + defaultOpen={true} // WARNING
- value={x} + defaultValue={y} // WARNING
Portal and stacking context
Radix Dialog and Popover use a Portal to render outside the React tree. This puts the modal at the root of the DOM, avoiding z-index conflicts with ancestor stacking contexts.
The trap: any CSS that creates a new stacking context on an ancestor of the Portal root breaks the layering. The common culprits:
| CSS property | Creates stacking context |
|---|---|
transform (non-none) |
Yes |
filter (non-none) |
Yes |
perspective |
Yes |
will-change: transform |
Yes |
isolation: isolate |
Yes |
position: fixed |
Yes |
overflow other than visible |
Sometimes |
If your Next.js layout wraps the page in a transform: translateZ(0) for GPU acceleration, the Portal'd dialog renders inside that stacking context and the overlay does not cover other parts of the page. The fix is to either remove the transform or target a different portal element.
Custom portal target:
import * as DialogPrimitive from '@radix-ui/react-dialog';
// In your component:
<DialogPrimitive.Portal container={document.body}>
<DialogContent>...</DialogContent>
</DialogPrimitive.Portal>
Setting container={document.body} is the safest default. It puts the dialog at the document root, bypassing any stacking context ancestors.
Add Portal rules to CLAUDE.md:
## Portal management
- Dialog and Popover use Portal by default (renders to document.body)
- If layout has transform/filter/perspective on an ancestor, Portal layering breaks
- Fix: container={document.body} on DialogPortal
- z-index on overlay: high enough to clear navigation, typically z-50
- z-index on content: same as overlay or higher
- NEVER apply transform to <body> or document.documentElement
Tooltip and Popover patterns
Tooltip and Popover share the floating-element model. Tooltips are non-interactive, dismiss on mouse leave, and are read by screen readers. Popovers are interactive, contain focusable content, and persist until dismissed.
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
<button aria-label="Save">
<SaveIcon />
</button>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={4}
className="rounded bg-black px-2 py-1 text-xs text-white"
>
Save changes
<TooltipPrimitive.Arrow className="fill-black" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
Two notes. First, TooltipPrimitive.Provider wraps your app (or a section of it) once, controlling shared tooltip behaviour. Second, even though the tooltip provides the label, the icon button still needs aria-label. The tooltip is supplementary, not the source of accessibility metadata.
Common Claude Code mistakes with Radix UI
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Missing asChild causes nested buttons
Claude generates: <Dialog.Trigger><button>Open</button></Dialog.Trigger> (renders <button><button> = invalid HTML).
Correct pattern: <Dialog.Trigger asChild><button>Open</button></Dialog.Trigger>.
2. Manual prop spreading instead of asChild
Claude generates: const trigger = useDialogContext(); <button {...trigger}>Open</button>.
Correct pattern: use Radix's asChild prop, never spread Radix props manually.
3. Missing DialogTitle
Claude generates: a Dialog with just content, no Title element.
Correct pattern: <DialogTitle>Heading</DialogTitle> is required by Radix for ARIA labelling (use <VisuallyHidden> if you do not want it visible).
4. Mixing controlled and uncontrolled
Claude generates: <Dialog open={open} onOpenChange={setOpen} defaultOpen={true}>.
Correct pattern: use controlled (open + onOpenChange) OR uncontrolled (defaultOpen only).
5. onClick instead of onSelect on menu items
Claude generates: <DropdownItem onClick={handler}>Item</DropdownItem> (works for click, not keyboard).
Correct pattern: <DropdownItem onSelect={handler}>Item</DropdownItem> (click + keyboard).
6. Conditional rendering of primitive Root
Claude generates: {showDialog && <Dialog>...</Dialog>} (loses Radix state machine on unmount).
Correct pattern: <Dialog open={showDialog} onOpenChange={setShowDialog}>...</Dialog> (control via state).
Add these as before/after blocks in CLAUDE.md.
Visually hidden helpers
Radix requires accessibility labels even when the design hides them visually. Use the visually-hidden pattern for these cases.
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
<Dialog>
<DialogContent>
<VisuallyHidden.Root>
<DialogTitle>Image gallery</DialogTitle>
</VisuallyHidden.Root>
<img src="/large-photo.jpg" alt="Large photo" />
</DialogContent>
</Dialog>
The title is announced to screen readers but does not appear visually. This pattern is essential when your dialog is image-driven and a visible title would clutter the design.
Install:
npm i @radix-ui/react-visually-hidden
Add to CLAUDE.md:
## Visually hidden content
- @radix-ui/react-visually-hidden for screen-reader-only content
- Use for required DialogTitle when design hides the title
- Use for icon buttons without visible label (sr-only span also works)
- NEVER display: none for accessibility content (removes from accessibility tree)
- VisuallyHidden uses clip-path technique that preserves screen reader visibility
Permission hooks for Radix workflows
{
"permissions": {
"allow": [
"Bash(npm i @radix-ui/react-*)",
"Bash(node scripts/check-a11y.js*)"
],
"deny": [
"Bash(rm -rf src/components/ui*)"
]
}
}
For broader accessibility patterns that pair with Radix, Claude Code best practices covers the keyboard testing patterns and screen reader verification steps that should run alongside any Radix integration.
Building Radix compositions that stay accessible
The Radix CLAUDE.md in this guide produces React applications where every primitive trigger uses asChild, DialogTitle is mandatory, controlled and uncontrolled state never mix, Portal targets respect stacking context, onSelect replaces onClick on menu items, and visually-hidden labels fill in for design constraints.
The underlying principle: Radix's accessibility contract is intricate and easy to break by composition mistake. Claude trained on generic React component patterns does not know Radix-specific rules without explicit instruction. Every rule you skip lets Claude generate composition that compiles and renders but breaks screen readers, keyboard navigation, or focus management.
For the styling system you pair with Radix, Claude Code with Tailwind covers the utility-class patterns, and Claudify includes a Radix-specific CLAUDE.md template with composition rules, controlled-state patterns, portal management, and all six common-mistake patterns pre-configured.
Get Claudify. Ship Radix compositions that pass accessibility audits.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify