Yugen UI

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 · TypeDefaultDescription
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.
sideOffsetnumberDefault: Popover 6 · HoverCard 6 · Menu 4 · Tooltip 4Distance (px) between the trigger and the surface.
alignOffsetnumberDefault: 0Shift along the alignment axis (positive = toward end).
avoidCollisionsbooleanDefault: trueFlip to the opposite side and slide along the axis so the surface stays inside the viewport.
collisionPaddingnumber | Partial<Record<Side, number>>Default: 0Padding from the edges of the collision area (for example, room for a sticky header).
collisionBoundaryElement | Element[]Default: viewportThe 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.
hideWhenDetachedbooleanDefault: falseHide 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 collisionPadding on that side so the surface does not slide under it.
  • Long lists: cap the height with the --radix-*-content-available-height variable 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.
Try it:scroll until the “Open popover” button above sits near the bottom of the screen, then open it with side="bottom". The surface appears above the button.

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 / dir through the usePortalThemeProps() 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 dir of the nearest provider, because React context (including DirectionProvider) 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.

Right-click here
SurfaceLocks scrollCan turn it off
Menu (also MenuButton, SplitButton, Breadcrumb overflow, DataGridColumnPicker)modal defaults to truemodal={false}
ContextMenumodal defaults to truemodal={false}
DropdownAlwaysNo; use scrollLock to keep the scrollbar
MenubarNo (Menubar menus are not modal)—
Popover and Popover-based components (Combobox, TagPicker, TimePicker…)No, unless modal—
Dialog, Drawer with an overlay, Sheet, TourYesBy 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 writes data-yugen-scroll-lock to <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: fixed at the right edge (headers, floating buttons) shift by the scrollbar width when the scrollbar hides, unless they have the right-scroll-bar-position class (or width-before-scroll-bar for full-width elements). With keep-scrollbar they 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).

LevelUsed for
z-30Sticky top bar (the docs site TopBar). Sticky app content stays at or below this level.
z-40Sidebar backdrop on mobile (docs site).
z-50All overlays: backdrop and surface of Dialog / Drawer, Popover, Menu, Tooltip, HoverCard, the Dropdown / Combobox listbox; mobile sidebar.
z-100Toast region. It sits above Dialogs so notifications are never covered.
Do
  • 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.
Don't
  • 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>.