shadcn + Radix Controlled-State Errors : The Open / onOpenChange Pair Rule, asChild Slot, Portal, and modal
Every Radix-wrapped shadcn primitive (Dialog, Sheet, Drawer, DropdownMenu, Popover, HoverCard, Select, AlertDialog, ContextMenu, Collapsible, Accordion, Tabs, Tooltip) shares one state-management contract. Break the contract and the symptom is always one of three : the primitive cannot close, the primitive crashes on render, or the primitive renders in the wrong place with the wrong focus. This skill documents the contract and the six recurring traps that violate it.
Companion skills :
shadcn-syntax-dialog(Batch B4) : Dialog composition surface and Radix wrap layersshadcn-syntax-popover-tooltip-hovercard(Batch B6) : Popover / Tooltip / HoverCard differences andmodalprop semanticsshadcn-syntax-drawer(Batch B5) : Drawer (Vaul) controlled stateshadcn-syntax-sheet(Batch B5) : Sheet (Radix Dialog variant)shadcn-syntax-menu-primitives(Batch B6) : DropdownMenu / ContextMenu / Menubar sharedonOpenChangeshadcn-syntax-selectors(Batch B6) : Select / Comboboxvalue+onValueChange(a parallel pair toopen+onOpenChange)shadcn-errors-form-state: controlled form state interacting with controlled dialog state (close-on-success pattern)
Quick Reference : The Open + onOpenChange Pair Rule (ALWAYS)
For EVERY Radix primitive with an open prop, the rule is binary :
- ALWAYS pass BOTH
openandonOpenChangetogether (controlled). - OR pass NEITHER and use
defaultOpenif a non-default initial state is needed (uncontrolled). - NEVER pass
openalone. The primitive becomes read-only : it reflects the prop but ignores Escape, the X button, the overlay click, and the explicitDialogCloseelement. The user cannot close it.
The same rule applies under different prop names :
| Primitive | Pair |
|---|---|
| Dialog, AlertDialog, Sheet, Drawer, Popover, HoverCard, DropdownMenu, ContextMenu, Menubar, Collapsible, Tooltip | open + onOpenChange |
| Select, Combobox | value + onValueChange |
| Accordion (single) | value + onValueChange |
| Accordion (multiple), ToggleGroup | value (string[]) + onValueChange |
| Tabs | value + onValueChange |
| RadioGroup, Switch, Checkbox | checked / value + onCheckedChange / onValueChange |
| Slider | value (number[]) + onValueChange |
For the open family the defaultOpen escape hatch is defaultOpen={true}.
For the value family the equivalent is defaultValue="...".
Verified at https://www.radix-ui.com/primitives/docs/components/dialog
(2026-05-19) : Root accepts open (boolean), onOpenChange (function),
defaultOpen (boolean), and modal (boolean, defaults to true).
Quick Reference : Why open-alone Makes The Primitive Read-Only
Radix internals : when open is provided, the primitive switches into
"controlled mode". In controlled mode, internal close handlers (Escape,
overlay click, DialogClose click, X button, focus-restoration) do
NOT mutate state directly. They call onOpenChange(false). The PARENT
is then responsible for setting open={false}.
If onOpenChange is missing, those internal handlers fire into the
void. The state stays at open={true} forever. The dialog is visually
locked open until the page reloads.
The fix is ALWAYS one of :
// CORRECT : controlled
const [open, setOpen] = React.useState(false)
<Dialog open={open}
// CORRECT : uncontrolled (let Radix manage state)
<Dialog defaultOpen>...</Dialog>
<Dialog>...</Dialog>
// WRONG : read-only, never closes
<Dialog open={true}>...</Dialog>
The exact same logic applies to value controlled primitives : value
without onValueChange makes a Select or Tabs read-only at the
current value.
Quick Reference : Per-Primitive Controlled Contract
| Primitive | State pair | Default modal |
Notes |
|---|---|---|---|
| Dialog | open / onOpenChange |
true |
Focus trap + scroll lock + outside-click closes |
| AlertDialog | open / onOpenChange |
true (not configurable) |
NO outside-click close ; only Action / Cancel |
| Sheet | open / onOpenChange |
true |
Dialog under the hood |
| Drawer (Vaul) | open / onOpenChange |
n/a | Snap points, drag-to-close ; not Radix Dialog |
| Popover | open / onOpenChange |
false |
Non-modal default ; can opt in to modal={true} |
| HoverCard | open / onOpenChange |
n/a (always non-modal) | Triggered by hover and focus, NOT click |
| DropdownMenu, ContextMenu, Menubar | open / onOpenChange |
n/a | Auto outside-click close, focus returns to trigger |
| Select | value / onValueChange AND open / onOpenChange |
n/a | Two separate pairs ; rarely need to control open |
| Collapsible | open / onOpenChange |
n/a | No portal, no focus trap |
| Accordion | value / onValueChange |
n/a | type="single" or type="multiple" |
| Tabs | value / onValueChange |
n/a | Tab list navigation managed internally |
| Tooltip | open / onOpenChange + TooltipProvider delayDuration |
n/a | Provider-driven delays ; controlled open is rare |
modal={true} (Dialog default) ALWAYS implies : focus trap, scroll
lock on <body>, click-outside-overlay closes, Escape closes. The
trap is implemented via aria-hidden on sibling DOM and a focus
sentinel on the content boundary.
modal={false} (Popover default) means : NO focus trap, NO scroll
lock, outside click still closes via onPointerDownOutside, focus is
NOT restored to the trigger on close unless explicitly handled.
Quick Reference : asChild Slot Contract
asChild={true} on a Radix trigger (DialogTrigger, PopoverTrigger,
DropdownMenuTrigger, etc.) tells the trigger to render its child
INSTEAD of the default <button>, merging all the trigger's props
(onClick, aria-expanded, data-state, ref) onto that child.
The contract is strict :
- EXACTLY ONE child. The Radix Slot uses
React.Children.onlyand throws "React.Children.only expected to receive a single child" if there are zero, two, or more children. - The child MUST accept and forward a
ref. Native DOM elements (<button>,<a>,<div>,<input>) all do. Custom components withoutReact.forwardRefdo NOT and will silently break focus, keyboard navigation, anddata-statestyling. - The child SHOULD accept the props the Slot forwards.
<div>acceptsonClickbut ignoresaria-expanded,aria-haspopup, anddata-state. The trigger then has no a11y semantics. ALWAYS use a focusable element (<button>,<a>with href, custom button component with forwardRef) as the asChild target. - If the child is a custom component, it MUST spread the props onto
the underlying DOM element.
function MyButton(props) { return <button {...props} /> }works ;function MyButton({ onClick }) { return <button /> }swallowsdata-state.
Example of the most common WRONG pattern :
// WRONG : Link from Next.js does forwardRef, but the icon is a SECOND child.
// Slot throws "React.Children.only".
<DialogTrigger asChild>
<Link href="/edit"><Icon /> Edit</Link>
</DialogTrigger>
// CORRECT : wrap children inside the single asChild target.
<DialogTrigger asChild>
<Link href="/edit">
<span><Icon /> Edit</span>
</Link>
</DialogTrigger>
The Slot pattern is documented at https://www.radix-ui.com/primitives/docs/utilities/slot (verified 2026-05-19). The single-child + forwardRef contract is enforced at render time, not at type-check time.
Quick Reference : Portal and Stacking-Context Traps
Every overlay primitive (Dialog, Sheet, AlertDialog, Popover,
DropdownMenu, HoverCard, ContextMenu, Tooltip, Select) renders its
content inside a Radix Portal that defaults to document.body.
This bypasses any local stacking context EXCEPT when a CSS property
on an ancestor of the <body>-attached portal would still affect
positioning anchors.
The five CSS properties that break Radix popper positioning :
transform: anything-non-noneon any ancestor (creates a containing block for positioned descendants in CSS, but Radix floating-ui anchors are computed againstgetBoundingClientRect()which IS still correct ; the symptom is usually z-index, not coordinates).filteron any ancestor (creates a containing block).perspectiveon any ancestor (creates a containing block).will-change: transform(creates a containing block lazily).contain: paintorcontain: layout(creates a containing block).
When any of these are present on an ancestor of the trigger AND the
portal lands at document.body, the portal content stacks against
the global stacking context but the trigger lives inside a NEW local
stacking context. The portal content can render BEHIND the trigger's
ancestor if z-index ordering is wrong.
The fix : use the Portal container prop to attach the portal to a
specific element you control :
<DialogPortal container={containerRef.current}>
<DialogOverlay />
<DialogContent>...</DialogContent>
</DialogPortal>
Or set the trigger's transformed ancestor to position: relative +
z-index: auto so it does not steal stacking.
A z-index above 1000 on the Radix overlay (default in shadcn's
dialog.tsx) usually wins. If it does not, the ancestor's stacking
context is the problem, not the z-index value.
See references/anti-patterns.md for the full trap matrix.
Quick Reference : Focus Management Contract
For modal primitives (Dialog, AlertDialog, Sheet, Drawer,
DropdownMenu, ContextMenu, Menubar, Select-open, Popover with
modal={true}) :
- ON OPEN : focus moves to the first focusable element inside the
content. Override via
onOpenAutoFocus={(e) => { e.preventDefault(); myInputRef.current?.focus() }}. - WHILE OPEN : Tab and Shift+Tab cycle within the content. Focus cannot escape (modal trap).
- ON ESCAPE :
onEscapeKeyDownfires, thenonOpenChange(false)fires. Callinge.preventDefault()inonEscapeKeyDowncancels the close. - ON OUTSIDE POINTER :
onPointerDownOutsidefires, thenonOpenChange(false). Callinge.preventDefault()cancels. - ON CLOSE : focus returns to the trigger element (or the element
that had focus before open). Override via
onCloseAutoFocus={(e) => { e.preventDefault(); myReturnRef.current?.focus() }}.
For non-modal Popover (modal={false}, the default) : focus does NOT
trap, outside click still closes, focus is NOT auto-restored on close.
Verified at
https://www.radix-ui.com/primitives/docs/components/dialog#content
(2026-05-19) : the Content exposes onOpenAutoFocus,
onCloseAutoFocus, onEscapeKeyDown, onPointerDownOutside, and
onInteractOutside.
Quick Reference : Close-On-Success Controlled Pattern
A controlled dialog tied to a form submit MUST set open={false}
inside the success branch. The mutation handler is the source of
truth, not the form's onSubmit.
const [open, setOpen] = React.useState(false)
const mutation = useMutation({
mutationFn: api.saveUser,
onSuccess: () => {
setOpen(false)
toast.success("Saved.")
},
})
return (
<Dialog open={open}
<DialogTrigger asChild><Button>Edit</Button></DialogTrigger>
<DialogContent>
<form => { e.preventDefault(); mutation.mutate(data) }}>
...
<Button type="submit" disabled={mutation.isPending}>Save</Button>
</form>
</DialogContent>
</Dialog>
)
ALWAYS call setOpen(false) in onSuccess. NEVER call it in the
submit handler's synchronous body : the mutation is async and the
dialog will close before the request resolves, losing the
isPending UI affordance.
For Popover-with-form-inside, use modal={true} to trap focus AND
prevent the form's first interactive element from being clicked
outside the popover by accident. See
references/examples.md for the full pattern.
Decision Tree : Why Will My Primitive Not Close?
Did you pass `open` to the root?
├── No -> Uncontrolled. Radix handles close internally.
│ If still stuck : check for `e.preventDefault()` in
│ `onEscapeKeyDown` / `onPointerDownOutside` / `onInteractOutside`.
└── Yes -> Did you ALSO pass `onOpenChange`?
├── No -> ROOT CAUSE : half-controlled state. Dialog is
│ read-only. Add `onOpenChange={setOpen}` (or remove
│ the `open` prop entirely).
└── Yes -> Does `onOpenChange` call `setOpen(value)`?
├── No (it does nothing) -> Same as half-controlled.
│ The state pair is broken at the consumer side.
└── Yes -> Is something calling `e.preventDefault()` in
`onEscapeKeyDown` / `onPointerDownOutside`?
OR is `open` derived from a memoized value
that never re-renders? OR is the mutation
callback never firing `setOpen(false)`?
Trace the `onOpenChange` call with a console
log : if it fires with `false` and the dialog
stays open, the consumer state setter is
broken.
Patterns : Per-Primitive Quick Checks
- Dialog / AlertDialog / Sheet : if Escape and overlay-click do not
close, you have
openwithoutonOpenChange. - Drawer (Vaul) : same pair rule ; additionally
dismissible={false}blocks drag-to-close even when state is correct. - Popover : default is non-modal. If you put a form inside, set
modal={true}to prevent focus-loss on first click. - DropdownMenu / ContextMenu / Menubar :
onOpenChangefires on outside click, Escape, and item-select withonSelect. If items do not close the menu on click, check fore.preventDefault()in the item'sonSelect. - Tooltip : do NOT control
openunless you need programmatic show-on-error patterns. UseTooltipProviderdelayDurationandskipDelayDurationinstead. - Select :
value+onValueChangeis the controlled pair (notopen). The popperopenstate is internal and rarely needs control.
Patterns : The data-state Hook For Debugging
Every Radix overlay primitive sets data-state="open" or
data-state="closed" on its trigger and content. Inspect the DOM :
<button data-state="open" aria-expanded="true" data-slot="dialog-trigger">...</button>
If data-state="open" but the dialog is invisible, the issue is
CSS / z-index / portal stacking, not state. If data-state="closed"
but the dialog is visible, you have stale styles (a closing-animation
that never finishes) or a stuck forceMount Portal.
Reference Links
- Companion :
shadcn-syntax-dialog(Dialog composition surface) - Companion :
shadcn-syntax-popover-tooltip-hovercard(modal prop deep dive) - Companion :
shadcn-syntax-drawer(Vaul drawer controlled state) - Companion :
shadcn-syntax-sheet(Sheet as Dialog variant) - Companion :
shadcn-syntax-menu-primitives(DropdownMenu / ContextMenu) - Companion :
shadcn-errors-form-state(form + controlled dialog) - Detailed signatures :
references/methods.md - Worked examples :
references/examples.md - Anti-patterns to avoid :
references/anti-patterns.md - Radix Dialog docs : https://www.radix-ui.com/primitives/docs/components/dialog
- Radix Popover docs : https://www.radix-ui.com/primitives/docs/components/popover
- Radix Slot utility : https://www.radix-ui.com/primitives/docs/utilities/slot
- shadcn Dialog reference : https://ui.shadcn.com/docs/components/dialog
- shadcn Popover reference : https://ui.shadcn.com/docs/components/popover