Overlay lifecycle
Dialog, Popover, Tooltip, HoverCard, Menu, Drawer, AlertDialog, and CommandPalette share a lifecycle contract so you can style enter/exit in CSS and distinguish why an overlay opened or closed.
These data-* attributes and --var-ui-* variables are public API (same semver rules as class names). Recipes already animate from them; you can target the same hooks in your own CSS.
Helpers live on @var-ui/react:
import {
useOverlayPresence,
usePositionerVars,
createOverlayChangeDetails,
type OverlayOpenChangeHandler,
} from '@var-ui/react';
Compound overlays already call these. Use the hooks directly only if you are building a custom overlay on React Aria.
Stacking, themed portals, and iOS 26 Safari backdrops are in Getting started.
State attributes
Spread on the popup (and backdrop, when there is one). Presence is an empty string when set, omitted when not.
| Attribute | Meaning |
|---|---|
data-open | Present while open, including during the exit animation until unmount |
data-closed | Present during the closing phase |
data-starting-style | Present for two animation frames after open (the enter “from” state) |
data-ending-style | Present while the close transition runs |
data-placement | RAC placement string on positioned popups (top, bottom start, …) |
data-open stays on the node through exit so &[data-open] selectors keep working (CommandPalette already uses that pattern).
Recipes transition opacity (and a slight scale on dialogs/popovers) from [data-starting-style] / [data-ending-style]. Reduced motion skips the duration and unmounts immediately.
.var-ui-popover[data-starting-style],
.var-ui-popover[data-ending-style] {
opacity: 0;
}
.var-ui-popover {
max-height: min(24rem, var(--var-ui-available-height));
transform-origin: var(--var-ui-transform-origin, center);
}
Exit before unmount
Closing does not remove the DOM until CSS animations on the popup (and backdrop, when wired) finish. prefers-reduced-motion: reduce unmounts immediately.
Positioner CSS variables
Set on positioned popups (Popover, Tooltip, HoverCard, Menu). Dialog and Drawer centered overlays use transform-origin: center.
| Variable | Source |
|---|---|
--var-ui-available-height | Viewport space remaining from the trigger |
--var-ui-available-width | Remaining inline size from the trigger |
--var-ui-transform-origin | Opposite the placement side (top → bottom center) |
--var-ui-anchor-width | Trigger width, when the overlay has a trigger ref |
Prefix is --var-ui- to match tokens. Values are ${px}px strings.
Change event details
Compound Dialog, Popover, Tooltip, HoverCard, Menu, Drawer, and AlertDialog call:
onOpenChange?: (open: boolean, details: OverlayChangeEventDetails) => void;
type OverlayOpenChangeReason =
| 'trigger-press'
| 'escape-key'
| 'outside-press'
| 'imperative'
| 'hover'
| 'focus'
| 'unknown';
type OverlayChangeEventDetails = {
reason: OverlayOpenChangeReason;
event: Event | null;
cancel: () => void;
isCanceled: boolean;
};
v1 reasons you can rely on: escape-key, outside-press, trigger-press. Hover/focus overlays also report hover and focus. Anything else may be unknown.
cancel() blocks the open-state update when the overlay is uncontrolled. If you pass isOpen, cancel() is a no-op — ignore open in the parent instead.
<Dialog.Root
onOpenChange={(open, details) => {
if (!open && details.reason === 'outside-press') {
details.cancel();
return;
}
setOpen(open);
}}
>
…
</Dialog.Root>
CommandPalette still uses onOpenChange?: (open: boolean) => void. It does spread presence attributes on the panel.
Helpers
useOverlayPresence({ isOpen, reducedMotion?, getAnimatedElements })
Returns { mounted, attrs }. Drive isOpen from the requested open state. Keep React Aria’s isOpen={presence.mounted} so the node (and getAnimations()) exist through exit. Pass popup and backdrop elements to getAnimatedElements.
usePositionerVars({ placement, popupRef, triggerRef? })
Returns { style } with the variables above. Merge onto the popup root with useLayer().style when you also use LayerProvider.
createOverlayChangeDetails({ reason, event })
Builds the details object with cancel() / isCanceled. Overlay components wrap RAC’s boolean onOpenChange with this.
Related
- Getting started —
isolation: isolate, themedportalContainer, iOS 26 backdrops - CSS variables — design tokens and component variables
- Dialog, Popover, Tooltip, Menu