Positioning
Popover, Menu, Tooltip, HoverCard and listboxes use Radix floating positioning: pick the side, the alignment and the offset. Surfaces flip when there is not enough room.
Positioning props
PopoverSurface, MenuPopover, MenubarContent, TooltipContent and HoverCardContent pass these props straight to Radix Popper. ContextMenu opens at the pointer, so it only takes the collision props.
| Prop · Type | Default | Description |
|---|---|---|
| side"top" | "right" | "bottom" | "left" | Default: "bottom" (Tooltip: "top") | The side of the trigger the surface attaches to. It is a physical direction: left / right do not swap in RTL. |
| align"start" | "center" | "end" | Default: PopoverSurface "center" · MenuPopover "start" | Alignment on the other axis. start / end follow the text direction; Yugen sets the provider's dir on the surface, so they swap in RTL. |
| sideOffsetnumber | Default: Popover 6 · HoverCard 6 · Menu 4 · Tooltip 4 | Distance (px) between the trigger and the surface. |
| alignOffsetnumber | Default: 0 | Shift along the alignment axis (positive = toward end). |
| avoidCollisionsboolean | Default: true | Flip to the opposite side and slide along the axis so the surface stays inside the viewport. |
| collisionPaddingnumber | Partial<Record<Side, number>> | Default: 0 | Padding from the edges of the collision area (for example, room for a sticky header). |
| collisionBoundaryElement | Element[] | Default: viewport | The area used for collision checks instead of the viewport. |
| sticky"partial" | "always" | Default: "partial" | Keep the surface in the viewport when the trigger scrolls partly / fully out of view. |
| hideWhenDetachedboolean | Default: false | Hide the surface when the trigger is fully hidden (for example, scrolled out of a scroll container). |
After positioning, Radix writes the actual data-side and data-align (also after a flip) on the surface, and provides the CSS variables --radix-popover-content-available-height, --radix-popover-trigger-width… (the prefix depends on the component: tooltip, dropdown-menu, select, hover-card).
Try side & align
Pick values, then open the popover. Try side="right" on a narrow screen to see the surface flip.
<Popover>
<PopoverTrigger asChild>
<Button appearance="primary">Open popover</Button>
</PopoverTrigger>
<PopoverSurface side="right" align="start" sideOffset={16} withArrow>
Content
</PopoverSurface>
</Popover>
{/* Tooltip defaults to side="top" */}
<SimpleTooltip content="Copy" side="bottom">
<Button icon={<Copy20Regular />} aria-label="Copy" />
</SimpleTooltip>Collisions & flipping
By default a surface never overflows the viewport. Without room on the chosen side it flips to the opposite side; without room along the alignment axis it slides inward.
{/* Leave room for a 74px top bar and a 16px margin on the other edges */}
<PopoverSurface
side="bottom"
align="start"
collisionPadding={{ top: 80, right: 16, bottom: 16, left: 16 }}
className="max-h-(--radix-popover-content-available-height) overflow-y-auto data-[side=top]:origin-bottom"
>
…
</PopoverSurface>
{/* Stay on the chosen side and accept clipping (rarely needed) */}
<MenuPopover avoidCollisions={false}>…</MenuPopover>- Radix does not know about your sticky header. Set
collisionPaddingon that side so the surface does not slide under it. - Long lists: cap the height with the
--radix-*-content-available-heightvariable and let the list scroll, instead of letting the surface grow taller than the viewport (Dropdown already does this). - Animations by direction: style with
data-[side=top]:…, because the actual side can differ from the one you picked.
Portals & theme
Every floating surface renders through a portal into <body> to escape overflow: hidden and the parent's z-index. It still picks up the theme, brand and text direction of the nearest nested YugenProvider.
This area is a YugenProvider with theme="dark" brand="web".
<YugenProvider theme="dark" brand="web" className="p-4">
<Popover>
<PopoverTrigger asChild>
<Button appearance="primary">Open popover</Button>
</PopoverTrigger>
{/* Picks up the dark theme and Web brand of the nested provider, even though it renders into <body> */}
<PopoverSurface withArrow>Dark background, Web brand.</PopoverSurface>
</Popover>
</YugenProvider>
// Writing a new portal surface? Spread the props of the nearest provider:
import { usePortalThemeProps } from "@/components/ui";
function MyFloatingSurface(props) {
const portalTheme = usePortalThemeProps(); // {} under the root provider
return <SomePrimitive.Portal><SomePrimitive.Content {...portalTheme} {...props} /></SomePrimitive.Portal>;
}- App-wide theme: use
<YugenProvider applyToDocument>at the root. The attributes sit on<html>, so every portal gets them. - Nested theme: every Yugen floating surface (PopoverSurface, MenuPopover, TooltipContent, HoverCardContent, DialogSurface, DrawerSurface, ContextMenu, Menubar, the Dropdown / Combobox / TagPicker listbox) sets the nested provider's
data-theme/data-brand/dirthrough theusePortalThemeProps()hook. Call this hook in surfaces you write yourself. - Text direction needs no extra work: floating surfaces (PopoverSurface, TooltipContent, HoverCardContent, DialogSurface, DrawerSurface, menus and listboxes) set the
dirof the nearest provider, because React context (includingDirectionProvider) passes through portals.
Scroll lock
Menu, ContextMenu and Dropdown lock page scrolling while open: wheel and touch scrolling outside are blocked, the page scrollbar is hidden and the body gets a margin so content doesn't shift. The app chooses whether the scrollbar stays.
| Surface | Locks scroll | Can turn it off |
|---|---|---|
| Menu (also MenuButton, SplitButton, Breadcrumb overflow, DataGridColumnPicker) | modal defaults to true | modal={false} |
| ContextMenu | modal defaults to true | modal={false} |
| Dropdown | Always | No; use scrollLock to keep the scrollbar |
| Menubar | No (Menubar menus are not modal) | — |
| Popover and Popover-based components (Combobox, TagPicker, TimePicker…) | No, unless modal | — |
| Dialog, Drawer with an overlay, Sheet, Tour | Yes | By design: the content behind can't be used |
// The whole app: keep the scrollbar while Menu, ContextMenu or Dropdown is open
<YugenProvider applyToDocument scrollLock="keep-scrollbar">
<App />
</YugenProvider>
// Without the provider: set the attribute on <html>
<html data-yugen-scroll-lock="keep-scrollbar">
// One menu that doesn't lock scrolling
<Menu modal={false}>…</Menu>scrollLock="keep-scrollbar"on the root provider writesdata-yugen-scroll-lockto<html>: the scrollbar stays and nothing shifts, but wheel and touch scrolling outside the surface are still blocked. Apps without the provider set the attribute themselves. The default is"hide-scrollbar"for now; 1.0 switches it to"keep-scrollbar".modal={false}on Menu or ContextMenu: the page keeps scrolling, outside clicks reach the page right away, and the rest of the page is not hidden from screen readers. Good for menus opened from a toolbar inside long content.- Elements with
position: fixedat the right edge (headers, floating buttons) shift by the scrollbar width when the scrollbar hides, unless they have theright-scroll-bar-positionclass (orwidth-before-scroll-barfor full-width elements). Withkeep-scrollbarthey don't need it.
z-index scale
Use only a few fixed levels. Surfaces on the same level stack in the order they open (a portal opened later sits on top).
| Level | Used for |
|---|---|
z-30 | Sticky top bar (the docs site TopBar). Sticky app content stays at or below this level. |
z-40 | Sidebar backdrop on mobile (docs site). |
z-50 | All overlays: backdrop and surface of Dialog / Drawer, Popover, Menu, Tooltip, HoverCard, the Dropdown / Combobox listbox; mobile sidebar. |
z-100 | Toast region. It sits above Dialogs so notifications are never covered. |
- Keep floating surfaces in the component's portal; use collisionPadding for a sticky header.
- Pick the side by content: Tooltip on top, Menu and listbox below, side panels at the side.
- Keep z-index within the 30 / 40 / 50 / 100 scale.
- Setting arbitrary z-index values (z-[…] in the thousands) to “win” over overlays.
- Turning off avoidCollisions just to keep the surface on one side.
- Writing your own Portal without usePortalThemeProps(). The surface then takes the theme of <html>.