Popover

A floating, non-modal panel anchored to a trigger, for lightweight interactive content.

Installation

  1. Install the package.
npm install @hashintel/ds-components
  1. 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.

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.

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 onClose the 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 onClose wherever 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.Header title — the panel has no accessible name of its own, so the first content read is the user's only context.
  • Give header actions buttons autoFocus="never" so they don't take the popover's initial focus.

Keyboard

KeyAction
EscapeClose the popover and return focus to the trigger (requires onClose).
Tab from the last elementMove focus out of the popover, to the element after the trigger.
Shift+Tab from the first elementMove focus back to the trigger.

API reference

Popover

PropTypeDescription
children
ReactNode
triggerRef
Ref<Element>
position
Position
default: "bottom"
positionFromPoint
{ x: number; y: number; }
gapX
number
default: 8
gapY
number
default: 8
onClose
(() => void)
closeOnInteractOutside
boolean
default: true
initialFocusRef
RefObject<HTMLElement |⁠ null>
returnFocusRef
RefObject<HTMLElement |⁠ null>

Popover.Container

PropTypeDescription
children
PopoverPanel |⁠ PopoverPanel[]

Popover.Header

PropTypeDescription
title
ReactNode
actions
ReactNode
hideCloseButton
boolean

Popover.Body

PropTypeDescription
withPadding
boolean
default: true

Popover.Footer

PropTypeDescription
children
ReactNode
actions
ReactNode
secondaryActions
ReactNode

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?

On this page