Skip to content

Overlays inside other libraries' modals, and in RTL pages - #216

Merged
deebov merged 2 commits into
v9from
compat/interop
Oct 8, 2026
Merged

deebov merged 2 commits into
v9from
compat/interop

Conversation

@deebov

@deebov deebov commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Makes our overlays usable inside other libraries' modals (shadcn/ui Dialog, Sheet, AlertDialog, Drawer on Radix or Base UI), keeps other libraries' menus usable inside ours, and makes popovers follow an RTL page.

Problems (found by the shadcn compatibility matrix)

  • React Aria portals popovers and modals to document.body. A Radix modal sets pointer-events: none on <body>, traps focus and handles Escape in a capture listener on document, so our Select/ComboBox/Dropdown/DatePicker options could not be clicked inside their Dialog or Sheet, our Modal opened from their Dialog was unclickable, and Escape in our Select closed their whole dialog.
  • In the other direction, React Aria's modal makes nodes added to <body> inert, so a shadcn DropdownMenu opened from our Modal could not be clicked (Radix and Base UI), and Escape in it closed our Modal too (Radix: focus was pulled back into our dialog).
  • React Aria takes direction from its locale (the browser language), not from <html dir>, so our popovers rendered LTR in RTL pages.

Fix

One helper, utils/overlay-host.ts (hooks), plus utils/overlay-popover.tsx (a drop-in for React Aria's Popover):

  • When the trigger sits inside another library's dialog ([role=dialog|alertdialog] without React Aria's data-rac), the overlay renders into that dialog (UNSTABLE_portalContainer). Modals use the dialog that holds focus when they open, and are offset back over the viewport when that dialog is transformed (Tailwind's translate makes it the containing block of fixed children). A window capture listener marks Escape in our hosted overlays as handled, so Radix leaves its dialog open.
  • While our Modal / SlideoutMenu is open, overlay roots other libraries add to <body> get data-react-aria-top-layer (React Aria's own escape hatch for toasts): not inert, focus and clicks allowed. Marked on insert and on focus-in.
  • When the page sets dir and the browser-default locale disagrees (no I18nProvider), popovers get an I18nProvider with the page's lang (or a locale of that direction). Apps that set their own I18nProvider are left alone.
  • Physical popover placements become logical (bottom end, end top, bottom start): identical in LTR, mirrored in RTL. Sidebar popovers stay physical (our sidebars don't mirror).

Existing users

No change without a foreign dialog or an RTL page: same portal (document.body), no new attribute. Verified by building a normal Untitled UI Next.js project with the published components and the same project with this branch: all #ours items and interactions (focus ring, open Select, open Dropdown, Tooltip, open Modal) pixel-identical in light and dark; in an RTL page only the open Select/Dropdown change (they are now RTL).

Evidence and matrix results: see the website PR / compat/interop branch.

🤖 Generated with Claude Code

Popovers (Select, ComboBox, MultiSelect, Dropdown, DatePicker, DateRangePicker,
nav account menus) and modals (Modal, SlideoutMenu) opened from inside another
library's modal layer (Radix/Base UI: shadcn/ui Dialog, Sheet, AlertDialog,
Drawer) portalled to document.body, where Radix's pointer-events: none, focus
trap and document-level Escape made them unusable. They now render into that
layer (utils/overlay-host.tsx, React Aria's UNSTABLE_portalContainer);
full-screen overlays are offset back over the viewport when the layer is
transformed, and Escape in them closes only them. Menus of other libraries
opened from our Modal or SlideoutMenu are marked as React Aria top layers so
they stay clickable and focusable. Without a foreign layer nothing changes:
same portal, same DOM.

React Aria takes direction from its locale, not the page: when the page sets
dir and the browser-default locale disagrees (no I18nProvider), popovers get a
locale for the page's direction (the page's lang when it matches). Physical
popover placements become logical (bottom end, end top), identical in LTR.
…nent in overlay-popover.tsx

Keeps Vite's fast refresh rule (only-export-components) quiet in users' projects.
@deebov
deebov changed the base branch from main to v9 October 1, 2026 11:20
@deebov
deebov marked this pull request as ready for review October 8, 2026 23:43
@deebov
deebov merged commit 1d1a5e9 into v9 Oct 8, 2026
1 check passed
@deebov
deebov deleted the compat/interop branch October 8, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant