Replies: 1 comment
-
|
The decoupling instinct is right, but worth flagging: the overlay-kit Radix guide still leans on Two ways to drop the timer entirely: 1. One persistent controlled Dialog at the parent, switch content by action. This is the pattern that scales best for a fixed set of menu actions. You never mount/unmount per action, so there's nothing to time. Radix runs its own exit animation and unmounts its internals when type Action = "edit" | "delete" | null;
const [action, setAction] = useState<Action>(null);
<DropdownMenu.Root>
<DropdownMenu.Trigger>...</DropdownMenu.Trigger>
<DropdownMenu.Content>
{/* Let the item close the menu normally; the state update opens the Dialog. */}
{/* Do NOT call e.preventDefault() here, or the menu stays open. */}
<DropdownMenu.Item onSelect={() => setAction("edit")}>
Edit profile
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setAction("delete")}>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Dialog.Root open={action !== null} onOpenChange={(o) => !o && setAction(null)}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content>
{action === "edit" && <EditProfile />}
{action === "delete" && <ConfirmDelete />}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>The menu fully unmounts and returns focus, then the Dialog opens from state. No focus fight, no stuck 2. If you genuinely want per-action mounting (the overlay-kit model), drive unmount off the real animation event instead of a fixed delay. Listen for <Dialog.Content
onAnimationEnd={(e) => {
if (e.currentTarget.dataset.state === "closed") unmount();
}}
>That's the part I'd push overlay-kit toward documenting as the default, since it removes the one piece that's still timing-dependent. For a bounded set of actions option 1 is hard to beat and needs no library. overlay-kit earns its keep when overlays are opened imperatively from deep in the tree or from non-React code (event handlers, async flows), where threading state up isn't practical, and the One note for anyone landing here from the focus/ |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
A few existing threads touch on this (#3317, #634, #2794):
Opening a Dialog from a DropdownMenu item without the two components fighting over focus and pointer-events.
The common workaround is setTimeout, but it's fragile and breaks with animations. We ended up building a thin layer that decouples the Dialog lifecycle from the Trigger:
Radix still owns accessibility, focus trapping, and scroll locking. The helper only manages mount/unmount timing.
We've been running this pattern in production at Toss for 2+ years across ~40 services and open-sourced it as overlay-kit (MIT, ~3KB).
Full guide with Radix:
https://overlay-kit.slash.page/en/docs/more/with-design-systems/radix-ui
GitHub: https://github.com/toss/overlay-kit
Would be curious to hear if others have found a cleaner approach, or if this pattern resonates.
Beta Was this translation helpful? Give feedback.
All reactions