Skip to content

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.

AttributeMeaning
data-openPresent while open, including during the exit animation until unmount
data-closedPresent during the closing phase
data-starting-stylePresent for two animation frames after open (the enter “from” state)
data-ending-stylePresent while the close transition runs
data-placementRAC 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.

VariableSource
--var-ui-available-heightViewport space remaining from the trigger
--var-ui-available-widthRemaining inline size from the trigger
--var-ui-transform-originOpposite the placement side (topbottom center)
--var-ui-anchor-widthTrigger 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.