Popover
A floating, non-modal panel anchored to a trigger, for lightweight interactive content.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { useRef, useState } from "react";
import { Button, Popover } from "@hashintel/ds-components";
export const DisplayOptionsButton = () => {
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<>
<Button
ref={triggerRef}
iconName="sliders"
onClick={() => setOpen((wasOpen) => !wasOpen)}
>
Display options
</Button>
{open ? (
<Popover triggerRef={triggerRef} onClose={() => setOpen(false)}>
<Popover.Container>
<Popover.Header title="Display options" />
<Popover.Body>Columns, density, and sort order.</Popover.Body>
</Popover.Container>
</Popover>
) : null}
</>
);
};When to use
- Do: Use Popover for lightweight interactive content anchored to a control: display options, filters, a quick rename, an entity preview.
- Do: Use it instead of Tooltip when the user must click or type in the overlay — tooltips are hover-only text.
When not to use
- Don't: Don't use a popover for short explanatory text on hover — use Tooltip, or HelpTooltip beside a form label.
- Don't: Don't use one for a list of actions or options — use Menu, which adds menu semantics and keyboard navigation.
- Don't: Don't use one for a blocking decision or a multi-section task — use Dialog; for long structured content, use Drawer.
Anatomy
<Popover triggerRef={triggerRef} onClose={close}>
<Popover.Container>
<Popover.Header title="Title" />
<Popover.Body>Content</Popover.Body>
<Popover.Footer actions={<Button>Apply</Button>} />
</Popover.Container>
</Popover>Children are free-form: Popover.Container draws the framed panel and accepts
only Header, Body, and Footer, each optional and repeatable — several bodies
render as separate cards.
The popover renders no trigger of its own — triggerRef points at whatever
element opens it, anchoring the panel's position and receiving focus back when
it closes.
Examples
Opening and closing
There is no open prop — render the popover to open it, and remove it from
the tree in onClose. Clicks on the trigger never count as outside
interactions, so a toggle trigger like this closes it without immediately
reopening.
Header
Popover.Header renders title as an uppercase label, with actions beside
the built-in close button. The close button renders only when the popover has
onClose; set hideCloseButton when a footer action dismisses it instead.
Footer actions
Popover.Footer right-aligns actions and left-aligns secondaryActions.
Put the confirming action in actions and reset or escape actions in
secondaryActions.
Sectioned bodies
Each Popover.Body renders as its own inset card — stack several to group
related content without custom dividers.
Positions
position sets the preferred placement: four sides, each with optional
-start/-end alignment. When it doesn't fit the viewport, the popover flips
to the opposite side.
Anchoring to a point
positionFromPoint anchors to a point relative to the trigger's top-left
corner instead of its edges. The point is re-measured every frame, so the
popover tracks a node while a chart pans or zooms; the cross-axis gap then
offsets the panel across the placement.
import { useRef, useState } from "react";
import { Popover } from "@hashintel/ds-components";
type GraphNode = { name: string; x: number; y: number };
export const EntityGraph = () => {
const canvasRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
return (
<div ref={canvasRef}>
{/* Canvas rendering; clicking a node calls setSelectedNode. */}
{selectedNode ? (
<Popover
triggerRef={canvasRef}
positionFromPoint={{ x: selectedNode.x, y: selectedNode.y }}
position="right-start"
onClose={() => setSelectedNode(null)}
>
<Popover.Container>
<Popover.Header title={selectedNode.name} />
<Popover.Body>Entity details for the selected node.</Popover.Body>
</Popover.Container>
</Popover>
) : null}
</div>
);
};Staying open on outside clicks
Set closeOnInteractOutside={false} to protect in-progress input from stray
clicks. Escape and the close button still close the popover.
Initial and return focus
On open the popover focuses its first focusable element, and on close it
returns focus to the trigger. Pass initialFocusRef to focus a specific
control instead, and returnFocusRef when the trigger will be gone by the
time the popover closes.
Behavior
- Positioning tracks the trigger as it moves, resizes, or scrolls; a point anchor is re-measured every frame, so it can follow a node while a chart pans or zooms.
- The popover is non-modal: no backdrop, no focus trap, no scroll lock — the page behind stays interactive.
- Popovers stack: one opened from inside another layers above it, and interacting with the inner one doesn't dismiss the outer.
- Without
onClosethe popover is not dismissable: Escape and outside clicks do nothing and the header renders no close button — only unmounting closes it.
Content
- Title the popover with the object or task it shows, 1–3 words — the title renders as an uppercase label, so keep it short: "Display options", "Filter entities". No punctuation.
- Keep the body to a single task or detail; content that needs sections, scrolling, or a multi-step form belongs in a Dialog or Drawer.
- Footer labels follow Button content rules: verb-first, 1–3 words, with the
confirming action in
actions.
Accessibility
Popover renders the panel as a non-modal role="dialog".
On open, focus moves to the first focusable element inside the panel — the
built-in close button opts out — or to initialFocusRef. On close, it
returns to the trigger, or to returnFocusRef.
Tabbing past the panel's last element moves focus to the element after the trigger, as if the popover were inline after it.
Escape closes the popover when onClose is set; the built-in close button
is labelled "Close".
What you must do
- Make the trigger a focusable control, such as a Button holding
triggerRef— the popover renders no trigger of its own, so keyboard access depends on yours. - Provide
onClosewherever the user should be able to dismiss the popover — without it, Escape, outside clicks, and the close button are all disabled. - Lead the content with text naming its purpose, such as a
Popover.Headertitle — the panel has no accessible name of its own, so the first content read is the user's only context. - Give header
actionsbuttonsautoFocus="never"so they don't take the popover's initial focus.
Keyboard
| Key | Action |
|---|---|
Escape | Close the popover and return focus to the trigger (requires onClose). |
Tab from the last element | Move focus out of the popover, to the element after the trigger. |
Shift+Tab from the first element | Move focus back to the trigger. |
API reference
Popover
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | Any content to position; use Popover.Container to frame panels |
| triggerRef | Ref<Element> | The element the popover is positioned relative to. Positioning updates automatically as this element moves, resizes, or scrolls. |
| position | Position default: "bottom" | The preferred position of the popover; another may be chosen for a better fit. |
| positionFromPoint | { x: number; y: number; } | A point to position the popover from instead of the trigger's bounds, in coordinates relative to the trigger's top-left corner (values may fall outside it). The popover repositions as the point changes. |
| gapX | number default: 8 | The horizontal distance in pixels between the popover and the trigger. |
| gapY | number default: 8 | The vertical distance in pixels between the popover and the trigger. |
| onClose | (() => void) | Called when the popover requests to close (e.g. Escape or an interaction outside it). Required for the popover to be dismissable. |
| closeOnInteractOutside | boolean default: true | Whether a pointer interaction outside the popover and its trigger requests close via onClose. Requires onClose. |
| initialFocusRef | RefObject<HTMLElement | null> | The element to focus when the popover opens. Omit to focus the first focusable element inside. |
| returnFocusRef | RefObject<HTMLElement | null> | The element to return focus to when the popover closes. Omit to return focus to the trigger. |
Popover.Container
| Prop | Type | Description |
|---|---|---|
| children | PopoverPanel | PopoverPanel[] | One or more Header / Body / Footer panels making up the popover |
Popover.Header
| Prop | Type | Description |
|---|---|---|
| title | ReactNode | Rendered in the uppercase label style at the start of the header |
| actions | ReactNode | Content (typically buttons) rendered at the end, before the close button |
| hideCloseButton | boolean | Whether to hide the close button, which otherwise closes the popover via its onClose. |
Popover.Body
| Prop | Type | Description |
|---|---|---|
| withPadding | boolean default: true | Turn padding on/off. Used when the body content controls padding itself. defaults to true. If set, you will need to apply useAvoidScrollWidthChange yourself if the content height/width can change. |
Popover.Footer
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | Replaces the footer's action layout entirely with custom content — mutually exclusive with actions/secondaryActions. |
| actions | ReactNode | The overlay's main actions — confirm, cancel — aligned to the footer's end. |
| secondaryActions | ReactNode | Supporting actions aligned to the footer's start, kept apart from the main ones. |
Styling
Popover.Container draws the panel chrome (surface, radius, shadow) but has
no intrinsic width — set one via className.
className on the Popover styles the positioned content
element, and positionerClassName targets the outer positioner, which carries
the popover z-index token — override the layer there.
Was this page helpful?