Skip to content

Popover

A surface anchored to the control that opened it, holding content the user has to read or act on. Unlike a tooltip it is announced to screen readers, it can be tabbed into, and it opens on touch — so it is the right home for anything that matters.

popover/basic.tsx

Popover or tooltip?

The question is what the trigger is for, not how much text the popup holds.

  • If the trigger's purpose is to open the popup — the little "i" info icon is the usual case — it is a popover.
  • If the trigger does something else and the popup merely labels it, it is a tooltip.

That distinction decides who can reach the content. A tooltip is never announced, never tabbable, and never opens on touch; a popover is all three. So whenever the content matters, this is the component, and the info-icon pattern in particular belongs here rather than in a tooltip — see Opening on hover below, which gives pointer users the same feel without stranding everyone else.

Import

import { Popover } from "@forte-ui/react";

Anatomy

Popover.Popup collapses Base UI's Portal, Backdrop and Positioner into one part, so the common case is a single element. Its props are drawn from all four.

<Popover.Root>
  <Popover.Trigger />
  <Popover.Popup>
    <Popover.Arrow />
    <Popover.Title />
    <Popover.Description />
    <Popover.Viewport />
    <Popover.Footer>
      <Popover.Close />
    </Popover.Footer>
  </Popover.Popup>
</Popover.Root>

Title, Description and Close belong inside Popover.Popup: that is what wires up aria-labelledby and aria-describedby, and in a modal popover a close button outside the popup is unreachable.

The arrow

There is no arrow prop. Render <Popover.Arrow /> as a child of Popover.Popup and the wedge appears, pointing back at the trigger; leave it out and the popup is a plain rectangle. Nothing else is needed — it positions and rotates itself from the side the popup resolved to, and it takes the popup's own fill and hairline, so a re-skinned popover re-skins the arrow with it.

<Popover.Popup>
  <Popover.Arrow />
  <Popover.Title>Notifications</Popover.Title>
</Popover.Popup>

Two things to know. sideOffset has to leave room for it — the default 8px clears the default arrow, so only a smaller offset is a problem. And with a Popover.Viewport, keep the arrow outside the viewport, as a direct child of the popup: the viewport clips, and the arrow lives past the popup's edge.

Examples

Sides

side on Popover.Popup picks the edge the popup opens from, and align moves it along that edge. Both are hints: the popup flips to the opposite side when it would overflow the collision boundary, which is why the left and right popovers below may land elsewhere in a narrow frame. Alongside the four physical sides, "inline-start" and "inline-end" follow writing direction instead of staying pinned to a physical edge.

popover/placement.tsx

Sizes

size sets a width cap, not a width. The popup shrinks to fit its content and only grows this wide when the content asks for it — and it is clamped again to whatever room the positioner reports as available, so a wide popover on a narrow screen narrows rather than overflowing.

popover/sizes.tsx

Opening on hover

openOnHover on Popover.Trigger reveals the popup on hover as well as on press, with delay and closeDelay tuning the wait. This is the accessible version of the info-icon affordance: pointer users get the tooltip feel, while the content stays announced, tabbable and available on touch.

popover/open-on-hover.tsx
Monthly active users

A form inside

A popover can hold real controls. initialFocus on Popover.Popup says where focus lands when it opens — by default the first tabbable element, except on a touch open, where Base UI focuses the popup itself rather than throwing up the virtual keyboard.

popover/form.tsx

Popover.Footer is the action row. It is not a Base UI part; it exists for the same reason Dialog.Footer does, and takes the same align values — "between" being the one for a destructive action pushed away from the safe one.

modal on Popover.Root takes the page over: true locks page scroll and disables pointer interaction outside the popup, "trap-focus" traps the keyboard without either. backdrop on Popover.Popup adds the scrim, and belongs with modal={true} — on a non-modal popover a dim page would claim an inertness that is not there.

popover/modal.tsx

Tall content

The popup deliberately never scrolls. The Arrow is placed outside the popup's box by the positioner, so any overflow other than visible would crop the wedge off. Put the scroll on something inside instead — a ScrollArea is the ready-made answer — and cap it with --available-height, the room the positioner measured between the anchor and the viewport edge, which the popup passes down by inheritance.

popover/scrollable.tsx

Multiple triggers

One Popover.Root can serve several triggers. Each hands the popup a payload, and the popup reads it from the render-function form of Root's children — so the content is written once and only one popup element is ever mounted.

popover/multiple-triggers.tsx

Guard on payload being undefined. It is undefined before the first open, and again while the popup animates out, because Base UI keeps the popup mounted until the exit transition finishes.

Detached triggers

Popover.createHandle() connects a Popover.Root to triggers that are not its children, for when defining the popup next to its trigger is not practical — a toolbar at the top of a layout and its content at the bottom, say. Pass the same handle to the root and to every trigger. The handle is also an imperative remote: open(triggerId) and close().

popover/detached-trigger.tsx

Anything else on the page can open it too, without being a trigger at all:

Create the handle outside render. One made during render is a new object on every pass, and the root and its triggers stop recognising each other. open() takes a trigger id rather than a payload — the popup still has to be anchored to something — so give each detached trigger an explicit id.

Controlled, with multiple triggers

A controlled multi-trigger popover has two pieces of state: open, and triggerId naming the trigger it is anchored to. There is no separate onTriggerIdChange — the trigger that caused the change arrives on onOpenChange's event details, so both are updated from the one handler. Changing triggerId alone re-anchors the popup without closing it.

popover/controlled.tsx

Animating between triggers

Popover.Viewport animates the swap when one popup serves several triggers and its content changes between them. It keeps a clone of the outgoing panel mounted for the length of the transition, slides the two past each other in the direction the new trigger lies, and resizes the popup to follow.

popover/viewport.tsx

Two placement rules. Wrap only the content that changes, and keep Popover.Arrow outside the viewport: the viewport clips, and the arrow lives past the popup's edge. Without a viewport the content still swaps correctly — it just cuts rather than sliding, which is the right choice when the panels are similar enough that the movement would be noise.

Accessibility

Keyboard interactions
KeyBehaviour
Enter then SpaceOpens the popover from the trigger, and moves focus into the popup.
TabMoves through the popup's own controls. In a non-modal popover, tabbing past the last one continues into the page and closes the popover; with modal it cycles inside.
EscCloses the popover and returns focus to the trigger.

Base UI wires the trigger to the popup with aria-haspopup and aria-controls, gives the popup role="dialog", and points aria-labelledby / aria-describedby at Popover.Title and Popover.Description when they are present. Render a title in every popover that holds more than a sentence: it is the popup's accessible name, and without one a screen reader announces an unnamed dialog.

An outside press closes a non-modal popover, and so does focus leaving it. Both report through onOpenChange with a reason, and the event can be cancelled — which is how a popover holding an unsaved form asks before it goes.

In forced-colors mode the popup's shadow is stripped by the browser, so the component carries a transparent border that becomes the visible system-coloured boundary, and the arrow is drawn as two SVG paths rather than the traditional CSS-border triangle, which would degrade into a filled rectangle. Reduced motion is handled by the motion tokens: the travel collapses to 0px and the scale to exactly 1, leaving a plain opacity fade.

Theming

Every value below is a custom property you can override. --forte-popover-z-index is declared on the positioner and --forte-popover-backdrop-z-index on the backdrop; the viewport pair is declared on the viewport, and everything else on the popup.

Theming tokens for Popover
PropertyControlsDefault
--forte-popover-backdrop-z-indexStacking order of the backdrop; set through backdropClassName49
--forte-popover-z-indexStacking order of the positioner50
--forte-popover-bgPopup surface colourvar(--forte-color-overlay)
--forte-popover-fgPopup text colourvar(--forte-color-foreground)
--forte-popover-radiusCorner radiusvar(--forte-radius-surface)
--forte-popover-paddingPadding inside the popupvar(--forte-surface-p)
--forte-popover-gapVertical gap between the popup's childrenvar(--forte-space-3)
--forte-popover-shadowPopup shadow (stripped by the browser in forced-colors mode, which is why the element also carries .forte-hc-surface)var(--forte-shadow-4)
--forte-popover-border-widthWidth of the hairline that separates the popup from the page1px
--forte-popover-border-colorColour of that hairline. The arrow's outline follows it.var(--forte-color-border)
--forte-popover-font-sizeBase font size inside the popupvar(--forte-font-size-2)
--forte-popover-max-widthWidth cap, further clamped to the space the positioner reports as available; what the size prop sets — 16rem for sm, 26rem for lg20rem
--forte-popover-arrow-widthArrow base width1em
--forte-popover-arrow-heightArrow height, and the distance it is pushed out from the popup0.5em
--forte-popover-travelHow far the popup slides in from, per sidevar(--forte-travel-sm)
--forte-popover-enter-scaleScale the popup grows from and shrinks back tovar(--forte-scale-enter)
--forte-popover-enter-durationLength of the opening gesturevar(--forte-duration-normal)
--forte-popover-enter-easeCurve of the opening gesturevar(--forte-ease-spring-snappy)
--forte-popover-exit-durationLength of the closing gesturevar(--forte-duration-fast)
--forte-popover-exit-easeCurve of the closing gesturevar(--forte-ease-exit)
--forte-popover-resize-durationHow long the popup takes to resize between two triggers' content; only in play when a Popover.Viewport is presentvar(--forte-duration-normal)
--forte-popover-viewport-travelHow far the incoming and outgoing content slide during a swapvar(--forte-travel-sm)

Where you set these matters. Each one is declared on its own element, so setting it on an ancestor, on :root or on a theme scope has no effect — an element's own declaration wins over an inherited value. Override them on the element that declares them: className on Popover.Popup for most, positionerClassName for the stacking one, backdropClassName for the scrim's. The portal makes the ancestor route doubly useless: the popup renders on document.body, so nothing above the trigger is an ancestor of it in the first place.

To re-skin every popover at once, re-point the global tokens the defaults resolve to — --forte-color-overlay, --forte-color-border, --forte-radius-surface, --forte-surface-p, --forte-shadow-4, --forte-space-*, --forte-font-*, --forte-travel-sm. The popup only reads those and never re-declares them, so a value set on :root or a theme scope does reach it. The knobs with literal defaults — --forte-popover-max-width, --forte-popover-border-width, --forte-popover-arrow-width, --forte-popover-arrow-height and the two z-indexes — have no global token behind them, so those can only be changed on the element itself.

The hairline is worth a word, because Dialog and Drawer do without one. Those sit on a scrim that has already darkened the page behind them; a popover floats over undimmed content, where the overlay and the page background are a single step apart on the grey ramp and the shadow is doing nearly all the separating — which is not enough in dark mode, and less than that over a bright photo. --forte-popover-border-color: transparent gives you the shadow-only look back, and the arrow follows: its outline is the same token, so the wedge flattens in step with the surface rather than keeping an outline the popup no longer has.

The stylesheet also computes --forte-popover-enter-x / --forte-popover-enter-y from --forte-popover-travel and the resolved data-side, and --forte-popover-viewport-x from --forte-popover-viewport-travel and data-activation-direction. They are internal; change the travel tokens instead.

API reference

Popover.Root

Props for PopoverRoot
PropTypeDefaultDescription
actionsRefRefObject<PopoverRootActions | null>Imperative escape hatch. `close()` closes the popover; `unmount()` forces the popup out of the DOM, for when an exit animation is driven externally.
childrenReactNode | PayloadChildRenderFunction<Payload>The trigger and popup for this popover. May also be a render function receiving `{ payload }` from the trigger that opened it.
defaultOpenbooleanfalseWhether the popover is open when it first mounts. For a controlled popover use `open` instead.
defaultTriggerIdstring | nullThe initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`.
handlePopoverHandle<Payload>Associates this popover with detached triggers created through `Popover.createHandle()`, so one popup can serve triggers that live elsewhere in the tree.
modalboolean | "trap-focus"falseWhether the popover takes the page over while it is open. - `false` — the rest of the page stays scrollable and clickable. - `true` — page scroll is locked and pointer interaction outside the popup is disabled. - `'trap-focus'` — focus is trapped inside the popup, but scrolling and outside pointer interaction still work. With `true` or `'trap-focus'`, render a `Popover.Close` inside the popup: it is what a touch screen-reader user uses to get out, and with `true` it is also what switches focus trapping on.
onOpenChange((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void)Called when the popover wants to open or close. The second argument carries the reason (`'trigger-press'`, `'trigger-hover'`, `'trigger-focus'`, `'outside-press'`, `'escape-key'`, `'close-press'`, `'focus-out'`, `'imperative-action'`, `'none'`), the trigger involved, and can `cancel()` the change — which is how a popover holding an unsaved form asks before it closes.
onOpenChangeComplete((open: boolean) => void)Called after the open or close transition has finished. Use this rather than a timer when work has to wait for the popup to actually leave.
openbooleanWhether the popover is currently open. Pass this together with `onOpenChange` to control the popover.
triggerIdstring | nullWhich trigger the popover is currently attached to, for controlled multi-trigger setups. There is no separate `onTriggerIdChange` — read the new id off `eventDetails.trigger` inside `onOpenChange`.

Popover.Trigger

Props for PopoverTrigger
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeDelaynumber0How long the popover lingers after the pointer leaves, in milliseconds. Requires `openOnHover`.
delaynumber300How long the pointer must rest on the trigger before the popover opens, in milliseconds. Requires `openOnHover`.
handlePopoverHandle<Payload>Associates a detached trigger with the `Popover.Root` carrying the same handle, created once outside render with `Popover.createHandle()`.
idstringIdentifies the trigger. Also how `Popover.Root`'s `triggerId` names the active trigger in controlled multi-trigger mode.
nativeButtonbooleantrueWhether the rendered element is a real `<button>`. Set it to `false` when `render` replaces the button with something else (a `<div>`, a table row), so Base UI supplies the keyboard and role behaviour the element does not have natively.
openOnHoverbooleanfalseAlso open the popover when the trigger is hovered. This is the setting that turns an "i" info icon into the accessible alternative to a tooltip: hover reveals it for pointer users while press still reveals it for everyone else.
payloadPayloadData handed to the popover when this trigger opens it, so one popup can render different content per trigger. Read it from the render-function form of `Popover.Root`'s children.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverTriggerState>Replaces the rendered `<button>` with another element or component — `render={<Button variant="outline" />}` is the common case. The trigger's own neutral styling steps aside when this is present, so the two never fight over the cascade.

Popover.Popup

Renders the portal, the optional backdrop, the positioner and the popup as one part, so its props are drawn from all four.

Props for PopoverPopup
PropTypeDefaultDescription
alignAligncenterHow the popup lines up with the trigger along the chosen side.
alignOffsetnumber | OffsetFunction0Shifts the popup along the alignment axis, in pixels, or a function returning one.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullThe element the popup positions against, when it should not be the trigger. Accepts an element, a ref, a getter, or a virtual element — a text selection or a right-click point.
arrowPaddingnumber5Minimum distance, in pixels, the arrow keeps from the popup's corners before it is allowed to sit off-centre (`data-uncentered`).
backdropbooleanfalseRender a scrim behind the popup. Off by default — a popover normally leaves the page visible and usable. Turn it on with `modal` on `Popover.Root`, where the page is already inert and the scrim is what says so.
backdropClassNamestringAdditional class name(s) for the backdrop element. The popup's own `className` cannot reach it, since the backdrop is a sibling rendered inside this component. Also where `--forte-popover-backdrop-z-index` goes.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
collisionAvoidanceCollisionAvoidanceHow the popup reacts when it would overflow the boundary — whether it flips, shifts, or stays put.
collisionBoundaryBoundaryclipping-ancestorsThe boundary the popup tries to stay inside of.
collisionPaddingPadding5Space, in pixels, kept between the popup and the collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullWhere the portal renders. Defaults to `document.body`; point it at a container when the popup has to live inside a specific stacking or shadow root.
disableAnchorTrackingbooleanfalseStops the popup re-measuring the anchor on scroll and resize. Cheaper, but the popup drifts if the anchor moves.
keepMountedbooleanfalseKeeps the portal — and therefore the popup — in the DOM while the popover is closed. Needed when something inside must stay mounted (an iframe, a media element, uncommitted form state).
positionerClassNamestringAdditional class name(s) for the positioner element, which owns placement and `z-index`. Use it to re-stack a single popover through `--forte-popover-z-index`.
positionMethod"fixed" | "absolute"absoluteWhether the popup is positioned with `position: absolute` or `position: fixed`.
sideSidebottomWhich side of the trigger to place the popup on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction.
sideOffsetnumber | OffsetFunction8Gap between trigger and popup, in pixels, or a function returning one. When an `Arrow` is rendered this must exceed the arrow's height or the arrow overlaps the trigger; the default leaves room for the default arrow.
sizePopoverSizemdWidth cap for the popup. The popup shrinks to fit its content and only grows to this width when the content asks for it, so this is a ceiling rather than a fixed measure. Further clamped to the space the positioner reports as available.
stickybooleanfalseKeeps the popup glued to the trigger while it scrolls out of view instead of letting it detach.

Popover.Arrow

Props for PopoverArrow
PropTypeDefaultDescription
childrenReactNodeReplaces the built-in wedge. The default SVG inherits the popup's colours through `--forte-popover-bg`, so a custom skin usually needs nothing here.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Popover.Title

Props for PopoverTitle
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Popover.Description

Props for PopoverDescription
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Popover.Close

Props for PopoverClose
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
iconOnlybooleanfalseRender as a square button sized for a single icon — the corner "×" treatment. Enforces the 24px minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label`, since there is no text to announce.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverCloseState>Replaces the rendered `<button>`. The close button's own neutral styling steps aside when this is present.

Popover.Footer

Props for PopoverFooter
PropTypeDefaultDescription
alignPopoverFooterAlignendHow the actions are distributed along the footer. `"between"` is the pattern for a destructive action pushed away from the safe one.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Popover.Viewport

Props for PopoverViewport
PropTypeDefaultDescription
childrenReactNodeThe content that changes from trigger to trigger.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Popover.createHandle

Popover.createHandle<Payload>() returns a handle connecting a Popover.Root to detached Popover.Triggers. Pass it to the handle prop of both. It also exposes open(triggerId), close() and isOpen, which take effect only while a root using the handle is mounted.