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 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.
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.
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.
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.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
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.
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.
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.
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().
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.
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.
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
| Key | Behaviour |
|---|---|
| Enter then Space | Opens the popover from the trigger, and moves focus into the popup. |
| Tab | Moves 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. |
| Esc | Closes 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.
| Property | Controls | Default |
|---|---|---|
--forte-popover-backdrop-z-index | Stacking order of the backdrop; set through backdropClassName | 49 |
--forte-popover-z-index | Stacking order of the positioner | 50 |
--forte-popover-bg | Popup surface colour | var(--forte-color-overlay) |
--forte-popover-fg | Popup text colour | var(--forte-color-foreground) |
--forte-popover-radius | Corner radius | var(--forte-radius-surface) |
--forte-popover-padding | Padding inside the popup | var(--forte-surface-p) |
--forte-popover-gap | Vertical gap between the popup's children | var(--forte-space-3) |
--forte-popover-shadow | Popup 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-width | Width of the hairline that separates the popup from the page | 1px |
--forte-popover-border-color | Colour of that hairline. The arrow's outline follows it. | var(--forte-color-border) |
--forte-popover-font-size | Base font size inside the popup | var(--forte-font-size-2) |
--forte-popover-max-width | Width cap, further clamped to the space the positioner reports as available; what the size prop sets — 16rem for sm, 26rem for lg | 20rem |
--forte-popover-arrow-width | Arrow base width | 1em |
--forte-popover-arrow-height | Arrow height, and the distance it is pushed out from the popup | 0.5em |
--forte-popover-travel | How far the popup slides in from, per side | var(--forte-travel-sm) |
--forte-popover-enter-scale | Scale the popup grows from and shrinks back to | var(--forte-scale-enter) |
--forte-popover-enter-duration | Length of the opening gesture | var(--forte-duration-normal) |
--forte-popover-enter-ease | Curve of the opening gesture | var(--forte-ease-spring-snappy) |
--forte-popover-exit-duration | Length of the closing gesture | var(--forte-duration-fast) |
--forte-popover-exit-ease | Curve of the closing gesture | var(--forte-ease-exit) |
--forte-popover-resize-duration | How long the popup takes to resize between two triggers' content; only in play when a Popover.Viewport is present | var(--forte-duration-normal) |
--forte-popover-viewport-travel | How far the incoming and outgoing content slide during a swap | var(--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
| Prop | Type | Default | Description |
|---|---|---|---|
actionsRef | RefObject<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. | |
children | ReactNode | PayloadChildRenderFunction<Payload> | The trigger and popup for this popover. May also be a render function receiving `{ payload }` from the trigger that opened it. | |
defaultOpen | boolean | false | Whether the popover is open when it first mounts. For a controlled popover use `open` instead. |
defaultTriggerId | string | null | The initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`. | |
handle | PopoverHandle<Payload> | Associates this popover with detached triggers created through `Popover.createHandle()`, so one popup can serve triggers that live elsewhere in the tree. | |
modal | boolean | "trap-focus" | false | Whether 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. | |
open | boolean | Whether the popover is currently open. Pass this together with `onOpenChange` to control the popover. | |
triggerId | string | null | Which 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeDelay | number | 0 | How long the popover lingers after the pointer leaves, in milliseconds. Requires `openOnHover`. |
delay | number | 300 | How long the pointer must rest on the trigger before the popover opens, in milliseconds. Requires `openOnHover`. |
handle | PopoverHandle<Payload> | Associates a detached trigger with the `Popover.Root` carrying the same handle, created once outside render with `Popover.createHandle()`. | |
id | string | Identifies the trigger. Also how `Popover.Root`'s `triggerId` names the active trigger in controlled multi-trigger mode. | |
nativeButton | boolean | true | Whether 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. |
openOnHover | boolean | false | Also 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. |
payload | Payload | Data 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. | |
render | ReactElement<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.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How the popup lines up with the trigger along the chosen side. |
alignOffset | number | OffsetFunction | 0 | Shifts the popup along the alignment axis, in pixels, or a function returning one. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | The 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. | |
arrowPadding | number | 5 | Minimum distance, in pixels, the arrow keeps from the popup's corners before it is allowed to sit off-centre (`data-uncentered`). |
backdrop | boolean | false | Render 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. |
backdropClassName | string | Additional 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. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
collisionAvoidance | CollisionAvoidance | How the popup reacts when it would overflow the boundary — whether it flips, shifts, or stays put. | |
collisionBoundary | Boundary | clipping-ancestors | The boundary the popup tries to stay inside of. |
collisionPadding | Padding | 5 | Space, in pixels, kept between the popup and the collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Where 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. | |
disableAnchorTracking | boolean | false | Stops the popup re-measuring the anchor on scroll and resize. Cheaper, but the popup drifts if the anchor moves. |
keepMounted | boolean | false | Keeps 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). |
positionerClassName | string | Additional 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" | absolute | Whether the popup is positioned with `position: absolute` or `position: fixed`. |
side | Side | bottom | Which side of the trigger to place the popup on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction. |
sideOffset | number | OffsetFunction | 8 | Gap 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. |
size | PopoverSize | md | Width 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. |
sticky | boolean | false | Keeps the popup glued to the trigger while it scrolls out of view instead of letting it detach. |
Popover.Arrow
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Replaces the built-in wedge. The default SVG inherits the popup's colours through `--forte-popover-bg`, so a custom skin usually needs nothing here. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Popover.Title
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Popover.Description
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Popover.Close
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
iconOnly | boolean | false | Render 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. |
render | ReactElement<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
| Prop | Type | Default | Description |
|---|---|---|---|
align | PopoverFooterAlign | end | How the actions are distributed along the footer. `"between"` is the pattern for a destructive action pushed away from the safe one. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Popover.Viewport
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The content that changes from trigger to trigger. | |
className | string | Additional 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.