Fix: Radix UI Not Working — Popover Not Opening, Dialog Closing Immediately, or Styling Breaking
Quick Answer
How to fix Radix UI issues — Popover and Dialog setup, controlled vs uncontrolled state, portal rendering, animation with CSS or Framer Motion, accessibility traps, and Tailwind CSS integration.
The Problem
A Radix Popover or DropdownMenu doesn’t open when you click the trigger:
import * as Popover from '@radix-ui/react-popover';
function MyPopover() {
return (
<Popover.Root>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>
<p>Content here</p>
</Popover.Content>
</Popover.Root>
);
}
// Click "Open" — nothing happensOr a Dialog opens and immediately closes:
<Dialog.Root open={isOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>...</Dialog.Content>
</Dialog.Root>
// Opens for a split second, then closesOr Radix components render but have no visible styles — just raw, unstyled HTML:
The dialog overlay is invisible, content appears at the bottom of the pageOr focus trapping locks keyboard navigation inside a component with no way out:
Tab key cycles inside the Popover forever — can't escapeWhy This Happens
Radix UI is an unstyled, headless component library. It provides behavior, accessibility, and state management — not CSS. Every visual detail is your responsibility:
- Components require specific structure — each Radix primitive has required parts.
Popover.Rootwraps everything,Popover.Triggeropens it,Popover.Contentis the flyout, andPopover.Portalcontrols where in the DOM it renders. Skip one piece and the interaction chain breaks. - Controlled mode requires both
openandonOpenChange— passingopen={isOpen}withoutonOpenChangemeans Radix can’t update the state. The component opens (because a click sets it), but the next event tries to close it and fails because the state never changes. - Content renders in a portal by default —
Dialog.ContentandPopover.Contentrender throughPortalintodocument.body. This means CSS scoped to a parent container won’t reach them. If your styles depend on a wrapper class, the portal renders outside it. - No default styles ship with Radix — unlike Material UI or Chakra, Radix gives you zero CSS. The overlay is transparent, the content has no background, and positioning relies on your own CSS or data attributes.
Fix 1: Set Up Popover, Dialog, and DropdownMenu
Each component follows the same pattern — Root, Trigger, Portal, Content:
npm install @radix-ui/react-popover @radix-ui/react-dialog @radix-ui/react-dropdown-menuPopover:
import * as Popover from '@radix-ui/react-popover';
function UserPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button className="rounded-full bg-gray-200 p-2">
Settings
</button>
</Popover.Trigger>
{/* Portal renders content at document.body — escapes overflow:hidden */}
<Popover.Portal>
<Popover.Content
className="rounded-lg bg-white p-4 shadow-lg border"
sideOffset={5}
align="center"
>
<p>Popover content</p>
<Popover.Arrow className="fill-white" />
<Popover.Close className="absolute top-2 right-2" aria-label="Close">
✕
</Popover.Close>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}Dialog:
import * as Dialog from '@radix-ui/react-dialog';
function ConfirmDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button>Delete Item</button>
</Dialog.Trigger>
<Dialog.Portal>
{/* Overlay — must be styled or it's invisible */}
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 shadow-xl w-[400px]">
<Dialog.Title className="text-lg font-bold">
Are you sure?
</Dialog.Title>
<Dialog.Description className="text-gray-600 mt-2">
This action cannot be undone.
</Dialog.Description>
<div className="flex gap-4 mt-4 justify-end">
<Dialog.Close asChild>
<button className="px-4 py-2 rounded bg-gray-200">Cancel</button>
</Dialog.Close>
<button className="px-4 py-2 rounded bg-red-600 text-white">
Delete
</button>
</div>
<Dialog.Close className="absolute top-4 right-4" aria-label="Close">
✕
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}DropdownMenu:
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
function ActionMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button>Actions ▾</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="bg-white rounded-lg shadow-lg border p-1 min-w-[180px]"
sideOffset={5}
>
<DropdownMenu.Item
className="px-3 py-2 rounded cursor-pointer outline-none data-[highlighted]:bg-blue-100"
onSelect={() => console.log('edit')}
>
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
className="px-3 py-2 rounded cursor-pointer outline-none data-[highlighted]:bg-blue-100"
onSelect={() => console.log('duplicate')}
>
Duplicate
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.Item
className="px-3 py-2 rounded cursor-pointer outline-none text-red-600 data-[highlighted]:bg-red-100"
onSelect={() => console.log('delete')}
>
Delete
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}Fix 2: Controlled State — open + onOpenChange
Passing open alone freezes the component. You need both:
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
function ControlledDialog() {
const [open, setOpen] = useState(false);
async function handleDelete() {
await deleteItem();
setOpen(false); // Close after async action
}
return (
// CORRECT — both open and onOpenChange
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button>Delete</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<button onClick={handleDelete}>Confirm Delete</button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// WRONG — open without onOpenChange
<Dialog.Root open={isOpen}> {/* Dialog can never close */}
// WRONG — setting state in the trigger click
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<button onClick={() => setIsOpen(true)}>Open</button> {/* Don't do this */}
{/* Use Dialog.Trigger instead — it handles open/close correctly */}
</Dialog.Root>Prevent close on certain conditions:
<Dialog.Root
open={open}
onOpenChange={(newOpen) => {
// Prevent closing if form is dirty
if (!newOpen && isDirty) {
if (confirm('Discard changes?')) {
setOpen(false);
}
return;
}
setOpen(newOpen);
}}
>Fix 3: The asChild Pattern
asChild merges Radix behavior onto your custom element instead of rendering a default wrapper:
// WITHOUT asChild — Radix renders its own <button>
<Dialog.Trigger>
Open {/* Renders as: <button>Open</button> */}
</Dialog.Trigger>
// WITH asChild — Radix merges onto your element
<Dialog.Trigger asChild>
<button className="my-custom-button">
Open {/* Renders as: <button class="my-custom-button">Open</button> */}
</button>
</Dialog.Trigger>
// WITH asChild — custom component
<Dialog.Trigger asChild>
<MyButton variant="primary">Open</MyButton>
</Dialog.Trigger>
// Your component MUST forward ref for asChild to work
const MyButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, ...props }, ref) => (
<button ref={ref} {...props}>
{children}
</button>
)
);Common asChild mistake — wrapping an element that doesn’t forward ref:
// BROKEN — functional component without forwardRef
function BadButton({ children, ...props }) {
return <button {...props}>{children}</button>;
}
<Dialog.Trigger asChild>
<BadButton>Open</BadButton> {/* Ref not forwarded — won't work */}
</Dialog.Trigger>
// FIXED — use forwardRef
const GoodButton = React.forwardRef<HTMLButtonElement, any>(
({ children, ...props }, ref) => (
<button ref={ref} {...props}>{children}</button>
)
);Fix 4: Animate Open/Close with CSS or Framer Motion
Radix exposes data-state="open" and data-state="closed" attributes for CSS animations:
CSS animations:
/* Overlay fade */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog-overlay[data-state='open'] {
animation: fadeIn 200ms ease-out;
}
.dialog-overlay[data-state='closed'] {
animation: fadeOut 200ms ease-in;
}
/* Content slide up */
.dialog-content[data-state='open'] {
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.dialog-content[data-state='closed'] {
animation: slideDown 200ms ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from { opacity: 0; transform: translate(-50%, -45%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
@keyframes slideDown {
from { opacity: 1; transform: translate(-50%, -50%); }
to { opacity: 0; transform: translate(-50%, -45%); }
}Tailwind CSS with data-[state] selectors:
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]" />Framer Motion — wrap content in AnimatePresence:
import * as Dialog from '@radix-ui/react-dialog';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
function AnimatedDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button>Open</button>
</Dialog.Trigger>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild>
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
className="fixed top-1/2 left-1/2 bg-white p-6 rounded-lg"
initial={{ opacity: 0, x: '-50%', y: '-45%' }}
animate={{ opacity: 1, x: '-50%', y: '-50%' }}
exit={{ opacity: 0, x: '-50%', y: '-45%' }}
>
<Dialog.Title>Animated Dialog</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
}Fix 5: Portal and Styling Issues
Portal renders content at document.body, which can break scoped CSS:
// Problem: Tailwind dark mode class is on a wrapper, not on body
<div className="dark">
<Dialog.Root>
{/* Dialog.Content renders at body — outside .dark */}
</Dialog.Root>
</div>
// Fix 1: Render portal into a specific container
const containerRef = useRef<HTMLDivElement>(null);
<div className="dark" ref={containerRef}>
<Dialog.Portal container={containerRef.current}>
<Dialog.Content>...</Dialog.Content>
</Dialog.Portal>
</div>
// Fix 2: Put the dark class on <html> or <body> (recommended)
// In your layout:
<html className={isDark ? 'dark' : ''}>
// Fix 3: Disable portal entirely (content renders inline)
// Just remove <Dialog.Portal> wrapper
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
{/* No Portal — content renders where the component is in the tree */}
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="...">
Content here
</Dialog.Content>
</Dialog.Root>Fix 6: Combine Multiple Radix Components
Nesting Radix primitives (e.g., a Dialog inside a DropdownMenu) requires careful structure:
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
function MenuWithDialog() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
return (
// Dialog wraps DropdownMenu — not the other way around
<Dialog.Root open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button>⋮</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="bg-white rounded shadow p-1">
<DropdownMenu.Item className="px-3 py-2 outline-none data-[highlighted]:bg-gray-100">
Edit
</DropdownMenu.Item>
{/* Trigger the dialog from a menu item */}
<Dialog.Trigger asChild>
<DropdownMenu.Item
className="px-3 py-2 outline-none text-red-600 data-[highlighted]:bg-red-50"
onSelect={(e) => {
e.preventDefault(); // Prevent menu from closing
}}
>
Delete
</DropdownMenu.Item>
</Dialog.Trigger>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<Dialog.Title>Delete this item?</Dialog.Title>
<Dialog.Close asChild>
<button>Cancel</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Still Not Working?
DropdownMenu closes when clicking inside a sub-menu — sub-menus need DropdownMenu.Sub, DropdownMenu.SubTrigger, and DropdownMenu.SubContent. Using a regular DropdownMenu.Item with a nested menu causes the parent to close on any click because Radix treats it as a selection.
Tooltip doesn’t appear at all — Tooltip.Provider must wrap your app or at least the component tree containing tooltips. Without the provider, individual Tooltip.Root instances silently do nothing. Add <Tooltip.Provider delayDuration={200}> near your app root.
Select shows the wrong value after selection — Radix Select uses value/onValueChange (strings only). If you’re passing an object or number as the value, convert it to a string: value={String(selectedId)}. The onValueChange callback always receives a string.
Focus returns to the wrong element after closing — Radix returns focus to the trigger element by default. If the trigger was removed from the DOM (e.g., list item deleted), focus gets lost. Use onCloseAutoFocus to redirect: <Dialog.Content onCloseAutoFocus={(e) => { e.preventDefault(); someOtherRef.current?.focus(); }}>.
For related component issues, see Fix: React Event Handler Not Working and Fix: shadcn/ui Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: Million.js Not Working — Compiler Errors, Components Not Optimized, or React Compatibility Issues
How to fix Million.js issues — compiler setup with Vite and Next.js, block() optimization rules, component compatibility constraints, automatic mode, and debugging performance gains.