Skip to content

Fix: Radix UI Not Working — Popover Not Opening, Dialog Closing Immediately, or Styling Breaking

FixDevs ·

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 happens

Or 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 closes

Or Radix components render but have no visible styles — just raw, unstyled HTML:

The dialog overlay is invisible, content appears at the bottom of the page

Or focus trapping locks keyboard navigation inside a component with no way out:

Tab key cycles inside the Popover forever — can't escape

Why 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.Root wraps everything, Popover.Trigger opens it, Popover.Content is the flyout, and Popover.Portal controls where in the DOM it renders. Skip one piece and the interaction chain breaks.
  • Controlled mode requires both open and onOpenChange — passing open={isOpen} without onOpenChange means 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 defaultDialog.Content and Popover.Content render through Portal into document.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-menu

Popover:

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 allTooltip.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles