Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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, which means the same component can look perfect in one project and broken in another based purely on how the host framework treats portals, hydration, and CSS scoping.

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

Server-side rendering adds an extra failure mode. Radix uses React portals, which render into document.body on the client but have no equivalent during SSR. If you render <Dialog.Portal> server-side in Next.js App Router, the server output skips the portal contents entirely and the client mounts them after hydration — fine for closed dialogs but visible as a flash for any dialog that defaults to open. Frameworks handle this differently: Next.js suppresses the mismatch warning for portals, Remix logs it, and Astro’s React island shells render portals only after hydration which can delay first paint.

The third pattern is iOS Safari’s focus-trap behavior. Radix uses aria-modal and an internal focus scope to keep keyboard navigation inside an open dialog. iOS Safari’s WebKit handles inert and aria-hidden on the rest of the page slightly differently than Blink, and combined with the on-screen keyboard scroll-into-view behavior, you can get a dialog that appears to scroll the underlying page even though Radix correctly marks it inert. This is most often a overscroll-behavior and position: fixed interaction, not a Radix bug per se.

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">
            X
          </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">
            X
          </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>More</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>
  );
}

Fix 7: Platform Differences — SSR Portal Placement, shadcn/ui, iOS Safari Focus, RTL

The same Radix component behaves differently across SSR frameworks, install paths, and platforms. Knowing which matters for your stack saves hours of debugging.

SSR portal placement. Radix portals mount on document.body on the client. During SSR they render nothing (Next.js App Router) or fall back to a synthetic root (Pages Router with React 17 patterns). The visible symptom is a brief unstyled flash on first paint when a dialog defaults to open. To avoid it, render the portal content client-side only:

import { useEffect, useState } from 'react';

function ClientOnlyDialog({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return children;
}

Each framework handles this differently. Next.js App Router suppresses hydration warnings for portals but still flashes on SSR’d open dialogs. Remix logs the mismatch but renders. Astro’s React island shells render portals only after the island hydrates, which means dialogs only work inside client:load or client:visible islands — client:idle can delay the portal long enough to be noticeable.

shadcn/ui copy-paste vs raw Radix. shadcn/ui ships pre-styled Radix wrappers via the npx shadcn add CLI. The wrapper components forward refs correctly, set sensible defaults, and apply Tailwind classes you can edit. If you mix import * as Dialog from '@radix-ui/react-dialog' with shadcn’s import { Dialog } from '@/components/ui/dialog', the namespaces collide and ref forwarding can silently fail because shadcn components are flat exports, not Root/Trigger/Content namespaces. Stick to one import style per component.

iOS Safari focus trap and scroll. When a dialog opens on iOS Safari, the on-screen keyboard’s scrollIntoView behavior can shift the underlying page, defeating Radix’s inert lock. The fix is to apply position: fixed; overflow: hidden to the <body> while a dialog is open and restore on close. Radix exposes this via Dialog.Content’s onOpenAutoFocus and a body-lock helper:

<Dialog.Content
  onOpenAutoFocus={(e) => {
    document.body.style.overflow = 'hidden';
  }}
  onCloseAutoFocus={(e) => {
    document.body.style.overflow = '';
  }}
>

Combine this with overscroll-behavior: contain on the dialog content to stop pull-to-refresh from triggering inside a scrollable dialog body.

RTL support. Radix respects the dir attribute on a parent element. Wrap your app in <DirectionProvider dir="rtl"> from @radix-ui/react-direction, or set <html dir="rtl">. Positioning props like align="start" and side="left" swap their meaning automatically. Without the provider, side hints stay LTR even on RTL pages, which makes popovers open from the wrong edge. Custom data-[side] Tailwind classes still need manual mirroring — Radix can flip the position but not your transform-origin.

CSS layers and Tailwind v4. Tailwind v4 emits utilities into the utilities cascade layer. Radix’s data-state animations rely on attribute selectors with specificity (0,1,1). If you place Radix styles in an outer layer or strip layers entirely, animation utilities can lose to base styles. Importing Radix CSS (when using @radix-ui/themes) after Tailwind’s @import line fixes the layer order.

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(); }}>.

Portal renders content outside dark mode wrapper — your dark class is on a <div> deep in the tree, but the portal mounts on document.body. Move the dark class to <html> or pass an explicit container ref to Portal. Frameworks like next-themes already attach the class at <html> for this reason.

Hydration mismatch warning on SSR with an open dialog — defaulting open to true during SSR mounts the portal contents server-side but with no body to attach to. Render the dialog only after mount with a useEffect-gated boolean.

iOS Safari scrolls the background when dialog is open — body lock isn’t applied. Set body { overflow: hidden; position: fixed; width: 100% } on open and restore on close. Use Radix’s onOpenAutoFocus and onCloseAutoFocus callbacks rather than tracking state separately, so the lock survives async closes.

For related component issues, see Fix: Framer Motion Not Working, Fix: shadcn/ui Not Working, Fix: Remix Not Working, and Fix: React Portal Event Bubbling.

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