Popover
A small surface anchored to a trigger — measured against it, flipped and shifted to stay in view, and dismissed by Escape, an outside click, or Tab-ing away. It is non-modal: the page behind stays live, scrollable, and clickable, and popovers nest inside each other and inside a Dialog.Import
import { Popover } from "@hope-ui/components/popover";Popover is a compound component — a namespace object whose parts you arrange yourself; there is
no auto-composed shortcut. See the Anatomy below for every part and how they nest.
Usage
Popover.Root owns the state (open/close, positioning, ARIA) and renders no element of its own.
Popover.Trigger toggles the card and doubles as the thing it is positioned against; everything
inside Popover.Portal renders into a portal at the end of <body>, so no ancestor's overflow can
clip it.
<Popover.Root>
<Popover.Trigger render={(p) => <Button {...p}>Share</Button>} />
<Popover.Portal>
<Popover.Positioner>
<Popover.Content>
<Popover.Arrow />
<Popover.Title>Share this page</Popover.Title>
<Popover.Description>
Anyone with the link can view it. Change who has access in <a href="#">project settings</a>.
</Popover.Description>
</Popover.Content>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>Popover.CloseTrigger renders a themed CloseButton), a Popover needs a <ThemeProvider> ancestor fed a preset — as every styled hope-ui component does.Anatomy
The nesting is fixed: Portal lifts the layer out of the page's stacking and overflow contexts,
Positioner is the element the positioning engine measures and moves, and Content is the card
itself. The split between the last two is load-bearing — the card's enter/exit translate would
otherwise fight the translate() written onto the positioner.
| Part | Element | Description |
|---|---|---|
Popover.Root | — | State owner: open/close, positioning, ids, role, and the resolved recipe. Renders no host element — just a context provider. |
Popover.Trigger | button | Toggles the popover and is the default anchor. Wires aria-haspopup / aria-expanded / aria-controls, and is the one element an outside click is never measured against. Usually rendered as your own Button via render. |
Popover.Anchor | div | Optional. Positions the layer against something other than the trigger — a card, a row, a whole section. Carries no ARIA: a positioning reference is not a control. |
Popover.Portal | — | Portals the Positioner to document.body (client-only), so no ancestor's overflow or transform can clip the layer. |
Popover.Positioner | div | The measured layer. Its position/left/top/transform are written inline by the positioning engine; it reports the resolved side and alignment as data-side / data-align. |
Popover.Content | div | The card, and the behavior hub: focus restore, autofocus, dismissal, the ARIA role, and the id linking. Nothing is auto-rendered inside it. |
Popover.Arrow | div | Optional. The little square that points back at the anchor, pinned by measurement. Goes inside Content, whose relative is what its pin resolves against. |
Popover.Header | div | Optional. A layout column grouping the title and the description, spaced tighter than the card's own regions. No behavior and no ARIA — the labelling stays on Title and Description. |
Popover.Title | h2 | The heading. Self-registers its id as the card's aria-labelledby. |
Popover.Description | p | The supporting prose. Self-registers its id as the card's aria-describedby. |
Popover.CloseTrigger | button | Opt-in. A CloseButton wired to close the popover, placed in the card's trailing-top corner. |
Open state
Left uncontrolled, a Popover manages its own open state: the trigger toggles it, and Escape, an
outside click, or Tab-ing away close it. Set defaultOpen to have it start open.
<Popover.Root defaultOpen>
<Popover.Trigger render={(p) => <Button {...p}>Open</Button>} />
{/* … */}
</Popover.Root>Drive open as a controlled signal and handle onOpenChange to react to every path in and out —
the trigger, Escape, an outside click, focus leaving, and your own buttons all route through it.
const [open, setOpen] = createSignal(false);
<Popover.Root open={open()} onOpenChange={setOpen}>
{/* … */}
</Popover.Root>;Sizes
The size prop scales the card: its max width, its padding, and both of its rhythms — the gap between
the card's regions, and the tighter one inside a Popover.Header. The default is md. The scale stops
at lg on purpose — a popover is anchored to a control, and a viewport-filling surface is a
Dialog.
<Popover.Root size="sm">…</Popover.Root>
<Popover.Root size="lg">…</Popover.Root>Grouping the header
Popover.Header is an optional column around the title and the description. It is pure layout — no
behavior, no ARIA, and no id of its own: Popover.Title and Popover.Description register themselves
with the card wherever they are nested, so wrapping them changes nothing but the spacing.
That spacing is the point. The header's own gap is tighter than the one size puts between the card's
regions, so on a card holding a second region — a copy row, a form, a list — the labelled text
reads as one block set apart from it, rather than as siblings evenly spaced down the card.
<Popover.Content>
<Popover.Arrow />
<Popover.Header>
<Popover.Title>Share this page</Popover.Title>
<Popover.Description>Anyone with the link can view it.</Popover.Description>
</Popover.Header>
<div class="flex gap-2">{/* … */}</div>
</Popover.Content>A card that is only a title and a description needs no header — that is the shape in
Usage above. There is no Popover.Body or Popover.Footer to go with it, either: those
exist on Dialog to drive its inside-scroll mechanics, and a popover is a small
anchored surface that never scrolls itself. Lay the rest of the card out with your own elements.
Positioning
side picks which side of the anchor the card prefers, and align skids it along that side's cross
axis — twelve combinations in all. Open any cell below to see where it lands.
<Popover.Root side="top" align="start">…</Popover.Root>
<Popover.Root side="right" align="center">…</Popover.Root>
<Popover.Root side="bottom" align="end">…</Popover.Root>side and align are a preference, not a promise: near a viewport edge the card flips to the
opposite side and slides along its alignment axis to stay in view. What the parts report as
data-side / data-align is where the card actually landed — which is what the enter animation,
the scale origin, and the arrow's pin all key on.
Offsets and collisions
| Prop | Default | Type |
|---|---|---|
sideOffset | 8 | numberDistance from the anchor, in px. The default clears the arrow. |
alignOffset | 0 | numberSkid along the alignment axis, in px. |
flip | true | booleanFlip to the opposite side when the preferred one would overflow. |
shift | true | booleanSlide along the alignment axis to stay in view. |
collisionPadding | 8 | number | Partial<Record<Side, number>>Gutter kept between the card and the collision boundary. |
collisionBoundary | "clippingAncestors" | Element | Element[] | Rect | 'clippingAncestors'What the card must stay inside. Defaults to every clipping ancestor. |
arrowPadding | 8 | numberGutter kept between the arrow and the card's corners, so it never points at the radius. |
<Popover.Root side="top" flip={false} collisionPadding={16} sideOffset={12}>
{/* … */}
</Popover.Root>Logical sides
side also takes inline-start and inline-end, resolved against the reading direction of the
layer — so one value serves both directions. data-side still reports the physical side, because
where a box landed is geometry.
<Popover.Root side="inline-start">…</Popover.Root>dir of its own — a portaled layer inherits direction from <body>, and stamping one from the locale would override the dir your app declared. If a popover is anchored inside a subtree whose direction differs from the document's, put that dir on Popover.Positioner; it is forwarded like any native attribute, and it is what a logical side resolves against.Matching the anchor's width
matchAnchorWidth pins the card to the measured width of whatever it is anchored to — the trigger, or
a Popover.Anchor when one is mounted. The size cap does not apply to a matched card: a card
narrower than the control it points at would defeat the purpose, so size keeps only its padding and
its gaps.
<Popover.Root matchAnchorWidth>…</Popover.Root>The width tracks the anchor for as long as the popover is open, not just at the moment it opens — resize the window above and the matched card follows.
The measurements, as CSS custom properties
matchAnchorWidth is one way to spend a measurement every popover already publishes.
Popover.Positioner carries four custom properties, on every popover, whether or not you use this
prop:
| Property | What it holds |
|---|---|
| --anchor-width | The anchor's width, snapped to whole device pixels. |
| --anchor-height | The anchor's height, snapped the same way. |
| --available-width | Space left before the collision boundary, along the inline axis. |
| --available-height | Space left before the collision boundary — what a scrolling card caps itself with. |
Reach for them when the prop is not the shape you want — a floor rather than an exact match, or a card that scrolls instead of growing past the viewport:
<Popover.Positioner class="min-w-(--anchor-width)">
<Popover.Content class="max-h-(--available-height) overflow-y-auto">…</Popover.Content>
</Popover.Positioner>--available-height can make Chromium log ResizeObserver loop completed with undelivered notifications in development. The loop settles — the space available depends on the anchor and the viewport, not on the card's own height — so this is noise, not a bug to fix by turning autoUpdate off.Staying glued
The position is kept current as the page scrolls or resizes (autoUpdate, on by default). For an
anchor that moves under a CSS transform or an animation, opt into per-frame re-measurement with
trackAnchorMotion.
<Popover.Root autoUpdate={false}>…</Popover.Root>
<Popover.Root trackAnchorMotion>…</Popover.Root>The arrow
Popover.Arrow is opt-in and goes inside Popover.Content. It is measured, not guessed: it
re-pins itself whenever the card flips or shifts, and it carries the card's hairline and elevation
around its two outward edges, so the border reads as one continuous outline rather than stopping where
the arrow meets it. When the anchor is too narrow — or the card has been pushed so far by shift that
the arrow can no longer honestly point at the anchor's centre — the part says so with
data-uncentered, and hope's preset answers by hiding it rather than leaving a square pointing at
nothing.
<Popover.Content>
<Popover.Arrow />
{/* … */}
</Popover.Content>Portal
Popover.Portal renders the layer at the end of <body>, so a popover inside a scroll container, an
overflow: hidden card, or a transformed ancestor is never clipped by it — while the position stays
glued to the trigger as the container scrolls.
<Popover.Portal mount={someElement}>
<Popover.Positioner>…</Popover.Positioner>
</Popover.Portal>Pass mount to portal somewhere other than document.body.
Anchoring to something else
By default the card is positioned against its trigger. Wrap a Popover.Anchor around something
larger — a row, a card, a whole section — and the layer measures against that instead, while the
trigger keeps owning the toggle and the ARIA. Unmounting the anchor hands positioning back to the
trigger.
<Popover.Root side="right" align="start">
<Popover.Anchor class="flex w-72 items-center justify-between rounded-lg border p-3">
<span>Acme Marketing Site</span>
<Popover.Trigger render={(p) => <Button {...p}>Details</Button>} />
</Popover.Anchor>
{/* … */}
</Popover.Root>Popover.Anchor renders a bare <div> with no styling of its own — usually you want it worn by an
element that already exists, via render:
<Popover.Anchor render={(p) => <tr {...p}>{/* … */}</tr>} />Dismissal
A popover light-dismisses three ways, each independently switchable: Escape, a pointer-down outside the card, and focus landing outside it. All three are on by default. The last one is what makes a popover feel non-modal — Tab out of the card and it closes behind you — and it is the one worth turning off for a card the reader is meant to keep referring to while working elsewhere.
<Popover.Root closeOnFocusOutside={false}>…</Popover.Root>
<Popover.Root closeOnEscape={false} closeOnInteractOutside={false}>…</Popover.Root>The close button
Popover.CloseTrigger is opt-in — unlike Dialog.Content, Popover.Content never renders one
for you. A popover already closes on Escape, on an outside click, on Tab-out, and on a second press
of its trigger; a permanent ✕ on a small anchored surface is chrome most popovers don't want. Add one
where it earns its place, typically on a card holding a form.
<Popover.Content>
<Popover.CloseTrigger size="sm" />
{/* … */}
</Popover.Content>It is a CloseButton with the popover's close wiring pre-attached, so it
inherits every CloseButton prop (size, icon, class, render). Its close handler runs in front
of your own onClick, so calling event.preventDefault() cancels the close.
With a form
A popover is a fine home for a small form. On open, focus moves to the first focusable element inside
the card — which, with a CloseTrigger present, is the ✕ rather than the field. Point initialFocus
at the element that should get the caret instead; it is read after the card mounts, so it can name
something inside it. Closing returns focus to the trigger either way.
Project: Acme Marketing Site
const [nameInput, setNameInput] = createSignal<HTMLInputElement>();
<Popover.Content initialFocus={nameInput}>
<Popover.Title>Rename project</Popover.Title>
<Popover.CloseTrigger size="sm" />
<form onSubmit={save}>
<input ref={setNameInput} value={draft()} onInput={/* … */} />
<Button type="submit">Save</Button>
</form>
</Popover.Content>;Nested popovers
A Popover.Root can live inside another popover's content. Both layers portal to <body> as
siblings, so by every DOM measure the inner card sits outside the outer one — a shared layer stack
is what makes it a layer above instead:
- A click inside the inner card is not an outside press for the outer one, so the outer stays open.
- Focus moving into the inner card is not focus leaving the outer one, so
closeOnFocusOutsidedoesn't fire. - Escape peels one layer at a time — the first closes the inner popover, the second the outer — and focus follows the chain back down to whichever trigger opened each.
<Popover.Root>
<Popover.Trigger render={(p) => <Button {...p}>Filters</Button>} />
<Popover.Portal>
<Popover.Positioner>
<Popover.Content>
<Popover.Title>Filters</Popover.Title>
<Popover.Root side="right" align="start">
<Popover.Trigger render={(p) => <Button {...p}>{status()}</Button>} />
<Popover.Portal>
<Popover.Positioner>
<Popover.Content>{/* … */}</Popover.Content>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</Popover.Content>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>Set bubbles if you'd rather one keystroke took the whole chain — per event channel, so Escape and
outside clicks can differ.
<Popover.Root bubbles={{ escapeKey: true }}>…</Popover.Root>Inside a Dialog
The same stack is what lets a popover open from inside a modal — on default props, with nothing to
configure. A modal Dialog marks everything outside its card inert and aria-hidden, cages focus
inside it, and listens for Escape on the document; the popover's layer portals out as a sibling of the
dialog's, and is nonetheless treated as sitting above it: the card stays hit-testable, keeps the focus
it is given, and takes the first Escape on its own.
<Dialog.Root>
<Dialog.Trigger render={(p) => <Button {...p}>Project settings</Button>} />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Body>
<Popover.Root>{/* … opens above the modal */}</Popover.Root>
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Portal>
</Dialog.Root>Non-modal by design
A Popover traps nothing: no focus trap, no scroll lock, no backdrop, no aria-modal, and no
pointer-blocking layer. The page behind stays live, scrollable, and clickable, which is what a
popover is for — it annotates the page rather than interrupting it.
When you need the interruption — a decision that must be made before anything else, a form that must
not be abandoned by a stray Tab — reach for Dialog instead. role="alertdialog"
is available on a Popover for a small anchored confirmation, and stays legal here precisely because a
non-modal layer never claims aria-modal.
<Popover.Root role="alertdialog">…</Popover.Root>Polymorphism
Render a part as a different element or component with the render prop — a function that receives
the computed props and spreads them onto your element. This is how Popover.Trigger becomes your own
Button. There is no as prop; render is the single polymorphism API.
<Popover.Trigger render={(props) => <Button {...props}>Open</Button>} /><Popover.Title render={(props) => <h3 {...props}>Share this page</h3>} />render target must spread every prop it is handed, ref included. The internal ref on Positioner, Content and Arrow is what the positioning, the dismissal and the arrow's measurement all ride on — a target that drops it breaks them silently, with no error.Theming
Popover's look comes from the active preset's recipe. It is a neutral surface — no color axis;
role accents belong on whatever you put inside the card. Override styling at three levels, applied in
order (later wins a Tailwind conflict): recipe base → preset slotClasses → instance slotClasses
/ part class.
Parts
Every styled part carries a data-slot attribute you can target, and each is addressable by name
through slotClasses. There is no root slot (Popover.Root renders no element), and none for
Trigger/Anchor — those render your element, and forward class untouched.
| Slot | data-slot | Description |
|---|---|---|
positioner | popover-positioner | The measured layer, and where the --anchor-width family is published. Stacking and width only — never anything positional. |
content | popover-content | The card surface. |
arrow | popover-arrow | The pointing square. |
header | popover-header | The title/description column, spaced tighter than the card's regions. |
title | popover-title | The heading line. |
description | popover-description | The supporting prose. |
closeTrigger | popover-close-trigger | Placement of the corner ✕ (its chrome comes from CloseButton). |
The positioner, the content and the arrow also report the resolved geometry as data-side and
data-align, and their transition state as data-presence — which is how the preset animates the
card in from the trigger's direction.
[data-slot="popover-content"][data-side="top"] { /* … */ }Overriding one popover
Set slotClasses on Popover.Root to reach any slot from one place, or put class on an individual
part. Use literal class strings so your Tailwind build can see them.
<Popover.Root slotClasses={{ content: "max-w-xs rounded-none", title: "text-base" }}>
{/* … */}
</Popover.Root>
// …or per part:
<Popover.Content class="max-w-xs rounded-none">…</Popover.Content>App-wide defaults & overrides
Set the default size and global part classes for every Popover through the theme with
definePreset, then pass the derived preset to ThemeProvider. defaultProps resolve at instance
?? preset ?? builtin precedence, so an explicit prop on a single popover always wins.
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
export const myPreset = definePreset(hope, {
components: {
popover: {
defaultProps: { size: "sm" },
slotClasses: { content: "rounded-none" },
},
},
});size and matchAnchorWidth are the recipe's variants, so they are the props defaultable app-wide.
Positioning (side, align, the offsets), role, and the open/dismissal props are per-usage
decisions and stay on the instance.
API
Popover.Root
Popover.Root renders no host element, so it takes no native attributes — only the props below.
| Prop | Default | Type |
|---|---|---|
size | "md" | PopoverSizeCard width + padding scale: sm, md, lg. |
matchAnchorWidth | false | booleanPin the card to the anchor's measured width. The size cap does not apply to a matched card. |
role | "dialog" | "dialog" | "alertdialog"ARIA role for the card, threaded to Content. alertdialog for a small anchored confirmation. |
open | — | booleanControlled open state. Leave unset for uncontrolled (defaults to defaultOpen). |
defaultOpen | false | booleanInitial open state when uncontrolled. |
onOpenChange | — | (open: boolean) => voidCalled on every open/close request (trigger, Escape, outside click, focus out). |
side | "bottom" | "top" | "right" | "bottom" | "left" | "inline-start" | "inline-end"Preferred side of the anchor. The two inline values resolve against the layer's reading direction. |
align | "center" | "start" | "center" | "end"Alignment along the side's cross axis. |
sideOffset | 8 | numberDistance from the anchor, in px. The default clears the arrow. |
alignOffset | 0 | numberSkid along the alignment axis, in px. |
flip | true | booleanFlip to the opposite side when the preferred one would overflow. |
shift | true | booleanSlide along the alignment axis to stay in view. |
collisionPadding | 8 | number | Partial<Record<Side, number>>Gutter kept between the card and the collision boundary. |
collisionBoundary | "clippingAncestors" | Element | Element[] | Rect | 'clippingAncestors'What the card must stay inside. |
arrowPadding | 8 | numberGutter kept between the arrow and the card's corners. |
strategy | "absolute" | "absolute" | "fixed"CSS position used for the positioner. |
autoUpdate | true | booleanKeep the position current through scroll and resize. |
trackAnchorMotion | false | booleanRe-measure every animation frame — for an anchor that moves under a transform. |
closeOnEscape | true | booleanWhether Escape closes the popover. |
closeOnInteractOutside | true | booleanWhether a pointer-down outside the card closes the popover. |
closeOnFocusOutside | true | booleanWhether focus landing outside the card closes the popover. |
bubbles | false | boolean | { escapeKey?: boolean; outsidePress?: boolean }Whether an Escape or outside pointer-down consumed by a layer opened above this popover also closes it. Off for both channels: the topmost layer alone dismisses. |
slotClasses | — | SlotClasses<"popover">Per-slot class overrides, keyed by positioner, content, arrow, header, title, description, closeTrigger. |
children | — | JSX.ElementThe popover anatomy (Trigger + Portal > …). |
Popover.Content
| Prop | Default | Type |
|---|---|---|
initialFocus | — | () => HTMLElement | null | undefinedElement to focus on open, instead of the first focusable descendant. Read after the card mounts, so it can name an element inside it. |
render | — | (props) => JSX.ElementRender Content as another element or component. Receives Content's computed props, ref included. |
class | — | stringExtra classes merged onto the content slot (your utilities win conflicts). |
Plus every native <div> attribute.
Popover.Portal
| Prop | Default | Type |
|---|---|---|
mount | document.body | ElementWhere to portal the Positioner. |
children | — | JSX.ElementThe Positioner subtree. |
Parts
Popover.Trigger, Popover.Anchor, Popover.Positioner, Popover.Arrow, Popover.Header,
Popover.Title, and Popover.Description each accept their native element attributes plus a render
prop and a class.
Popover.CloseTrigger accepts every CloseButton prop; its close handler
is pre-wired (call event.preventDefault() in your own onClick to cancel the close).
Accessibility
Popover implements the WAI-ARIA dialog
pattern in its non-modal form; role="alertdialog" switches to the alert-dialog variant.
| Key | Behavior |
|---|---|
Enter / Space | On the trigger: toggles the popover. Pressing it again while open closes it. |
Escape | Closes the popover and returns focus to the trigger. With layers stacked, it closes the topmost one only. |
Tab / Shift+Tab | Moves through the card's focusable content and then out of it — leaving closes the popover, unless closeOnFocusOutside is off. Focus is never trapped. |
- Focus moves in and comes back. On open, focus moves to the first focusable element inside the
card — or wherever
initialFocuspoints, or the card itself when it holds nothing focusable. On close, it returns to the trigger, including when the popover is closed by Escape or from a layer opened above it. - The card has an accessible name.
Popover.Titleself-registers asaria-labelledby;Popover.Descriptionasaria-describedby. Arole="dialog"surface with no name is an axe violation, so give every popover aPopover.Title— or anaria-labelonPopover.Contentwhen a visible heading would be noise. - The trigger announces the relationship.
aria-haspopup="dialog",aria-expanded, and anaria-controlspresent only while the popover is open — an IDREF naming an element that isn't in the DOM is invalid, so it is omitted rather than left dangling. - Nothing is hidden from assistive tech. A popover is non-modal, so the page behind it is neither
inertnoraria-hidden— a screen-reader user can navigate straight past the card and back. - Motion is reduced-motion aware. The directional slide-and-fade drops to an instant open/close
when the user has
prefers-reduced-motionenabled.