Skip to content

Popover

Interactive content anchored to a trigger and dismissible with no consequence — including the hover-raised preview card.

Also called Hover Card, Coach Mark, Detail Popup, Anchored Overlay — in this system all of them are Popover.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Hover card

A popover raised by hover instead of click. The 500ms open and 300ms close delays are what make it reachable rather than a flicker.

Rolled back by after the health check failed in eu-west-2.

Popover or menu

The same shape, two different contracts. Commands with arrow keys are a Menu; mixed controls with Tab order are a Popover.

MenuCommands · arrow keys
RenameDuplicateArchive
PopoverMixed controls · Tab order

A short form

Filters are the archetypal popover: several controls, an explicit Apply, and nothing lost if the user changes their mind and clicks away.

Placement and collision

Top or bottom, aligned to either end of the trigger. Near a viewport edge the panel flips to the opposite side rather than being clipped — the anchor relationship survives, the clipping would destroy it.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Trigger closed
Trigger open
Interactive content
Panel
Display optionsBody
With header
Body
With footer
Icon trigger
ALAda Lovelace
Hover card
Pin the deployments you watch most.
Coach mark

Anatomy

Every part, every measurement, and the reason it is that number.

A panel anchored 6px below its trigger, at overlay elevation, sized to its content rather than to the trigger.

  1. WidthContent-sized, 12–24rem

    Not matched to the trigger. A popover from a 32px icon button is still 15rem wide, because the content decides.

  2. Max height50vh, then scrolls

    Past half the viewport it is a dialog wearing an anchor, and the anchor relationship stops meaning anything.

  3. Offset6px from the trigger

    Close enough to read as attached; far enough that the trigger’s focus ring is not clipped by the panel.

  4. Padding14px

    One step below a card. A popover is transient and does not need the breathing room a permanent surface earns.

  5. Elevation--shadow-e4

    Above the page, below a dialog. A dialog opened from a popover must clearly sit above it.

  6. CollisionFlip, then shift

    Flip to the opposite side first, then slide along the cross-axis. Both preserve the anchor; clipping destroys it.

  7. Entrancescale 0.96 → 1, 120ms

    Scaling from the anchor’s edge is what makes the panel read as emerging from the trigger rather than appearing over it.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-overlay—Panel surface
--ds-border—Panel edge
--ds-border-subtle—Header and footer dividers
--ds-surface-inset—Footer strip
--ds-fg-muted—Section labels inside the panel

Spacing

TokenValueUsed for
offsetGap from the trigger
paddingPanel padding

Radius

TokenValueUsed for
--radius-lgPanel corners

Shadow

TokenValueUsed for
--shadow-e4—Panel elevation

Motion

TokenValueUsed for
--duration-fastScale-in entrance
hover intentHover-card delays

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapMin widthMax widthWhen to use
Compact—10px—12rem—A couple of toggles or a short preview.
Default—14px—15rem20remThe default. Filters, display options, a short form.
Wide—16px——24remA hover card with an avatar and metadata, or a two-column set of options.
Max height50vh————Then the body scrolls with the header and footer pinned.
Offset——6px——From the trigger, on every side.
Viewport margin——8px——Minimum distance from any edge before flipping or shifting.

Do

onClose → triggerRef.current?.focus()
Return focus to the trigger on closeA keyboard user who presses Escape must land back on the control they opened. Dropping focus to the body sends them to the top of the page.
enter → 500ms → open·leave → 300ms → close
Give a hover card both delays500ms before opening so a pointer crossing a link never triggers it; 300ms before closing so the pointer can travel into the card.
Flip before you clipA panel cut off by the viewport edge loses both its content and its anchor. Flipping to the opposite side keeps both.
aria-haspopup="dialog" aria-expanded="true"
Use aria-haspopup="dialog", not "menu"The role sets the user’s expectation. Announcing a menu and then not providing arrow-key navigation is worse than announcing nothing.

Don't

Do not put a long form in oneAn outside click closes it with no warning. Anything worth confirming before discarding belongs in a Dialog.
Do not open a popover from a popoverTwo dismiss layers, two escape targets and a pointer path across both. Users cannot tell which click closes what.
…and it keeps going
Do not make it taller than half the viewportAt that size the anchor relationship stops meaning anything and the user is looking at a dialog that closes if they miss.
onMouseEnter → setOpen(true) → flicker
Do not open a hover card with no delayEvery pointer crossing the link fires it, and with no close delay the card cannot be reached before it disappears.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.13Content on Hover or FocusAA2.1.1KeyboardA2.1.2No Keyboard TrapA2.4.3Focus OrderA4.1.2Name, Role, ValueA

Contrast

  • The panel edge must reach 3:1 against the page behind it. On an overlay surface in dark mode this is the easiest boundary to lose.
  • A popover is not scrimmed, so its surface must be distinguishable from the content it covers by more than a shadow.
  • Controls inside inherit their own contrast requirements — being in a transient panel does not exempt them.

Keyboard

Enter / SpaceOn the trigger, opens the panel and moves focus to its first focusable element.
TabCycles within the panel while it is open. It is not a full focus trap, but focus must not escape into the page behind unnoticed.
EscCloses and returns focus to the trigger. Always.
Tab outFrom the last element, closes the panel and continues into the page — a popover is not a modal.
Arrow keysNothing at panel level. If the content wants arrows, it is a Menu.

Screen readers

  • The trigger announces as "Display options, button, collapsed" then "expanded" when opened.
  • The panel announces its own name on entry. A popover with no accessible name is a region the user has to explore to identify.
  • Do not announce hover cards on hover alone — the same content must be reachable by focusing the trigger, or it does not exist without a mouse.

Focus & touch

  • Opening moves focus into the panel; Escape returns it to the trigger. A hover card opened by hover must not steal focus — it opens on focus of the trigger too, so keyboard users reach it without the pointer. WCAG 1.4.13 also requires the card to stay open while the pointer is over it and to be dismissible with Escape.
  • There is no hover, so a hover card must have a tap behaviour: tap the trigger to open, tap outside to close. Panels near the bottom of a phone screen are covered by nothing but are hard to reach — prefer a Drawer from the bottom edge for anything with more than a couple of controls. Keep the trigger visible when the panel opens, or the anchor relationship is lost.
AttributeApplied toNotes
aria-haspopup="dialog"The triggerNot "menu" unless it genuinely is one. The announced role sets the keyboard expectation.
aria-expandedThe triggerMust track the real state. A hardcoded false is a silent and very common bug.
role="dialog"The panelWith aria-label or aria-labelledby pointing at its heading. An anonymous panel is announced as an unnamed region.
aria-controlsThe triggerPoints at the panel id, associating the two for assistive tech.
aria-modal="false"The panelExplicit: content behind stays reachable. That is the difference from a Dialog.

Code

Example usage

tsx
1import { Popover } from '@/ui/Overlay'23<Popover4  side="bottom"5  align="start"6  width="15rem"7  trigger={({ toggle, open }) => (8    <Button9      variant="outlined"10      // "dialog", not "menu": the announced role sets the keyboard expectation.11      aria-haspopup="dialog"12      aria-expanded={open}13      onClick={toggle}14    >15      Display options16    </Button>17  )}18>19  <ColumnSettings />20</Popover>2122// Hover card: BOTH delays, or it is a flicker that cannot be reached.23const enter = React.useRef<number>()24const leave = React.useRef<number>()2526const onEnter = () => {27  clearTimeout(leave.current)28  enter.current = window.setTimeout(() => setOpen(true), 500)29}30const onLeave = () => {31  clearTimeout(enter.current)32  // The 300ms is the pointer's travel time from the link into the card.33  leave.current = window.setTimeout(() => setOpen(false), 300)34}3536// Opening on focus too is what makes the card exist without a mouse.37<a onMouseEnter={onEnter} onMouseLeave={onLeave}38   onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}>39  @ada40</a>4142// Collision: flip to the opposite side first, then shift along the cross-axis.43// Both preserve the anchor; clipping destroys it.44const placement = fits(preferred) ? preferred : opposite(preferred)45const shift = clampToViewport(rect, 8)

Framework-free HTML

html
<button
  type="button"
  id="opts-trigger"
  aria-haspopup="dialog"
  aria-expanded="true"
  aria-controls="opts-panel"
>
  Display options
</button>

<div
  id="opts-panel"
  role="dialog"
  aria-modal="false"
  aria-labelledby="opts-title"
>
  <h2 id="opts-title" class="sr-only">Display options</h2>

  <fieldset>
    <legend>Columns</legend>
    <label><input type="checkbox" checked /> Status</label>
    <label><input type="checkbox" /> Region</label>
  </fieldset>

  <hr />

  <label><input type="checkbox" /> Compact rows</label>
</div>

CSS

css
.ds-popover {
  position: absolute;
  z-index: 80;                       /* above the page, below a dialog */
  /* Sized by content, not by the trigger: a popover from a 32px icon
     button is still 15rem wide. */
  inline-size: max-content;
  min-inline-size: 12rem;
  max-inline-size: 20rem;
  /* Past half the viewport this is a dialog wearing an anchor. */
  max-block-size: 50vh;
  overflow-y: auto;
  padding: 14px;
  border: 1px solid var(--ds-border);
  border-radius: var(--radius-lg);
  background: var(--ds-surface-overlay);
  box-shadow: var(--shadow-e4);
  /* Scaling from the anchor edge reads as emerging FROM the trigger. */
  transform-origin: var(--popover-origin, top center);
  animation: popover-in 120ms cubic-bezier(0.32, 0.72, 0, 1) both;
}

@keyframes popover-in {
  from { opacity: 0; scale: 0.96; }
  to   { opacity: 1; scale: 1; }
}

/* Header and footer stay put while the body scrolls. */
.ds-popover__head,
.ds-popover__foot { position: sticky; z-index: 1; }
.ds-popover__head { inset-block-start: 0; background: var(--ds-surface-overlay); }
.ds-popover__foot { inset-block-end: 0;  background: var(--ds-surface-inset); }

@media (prefers-reduced-motion: reduce) {
  .ds-popover { animation: none; }
}

/* No hover, and the lower half of a phone screen is hard to reach: past a
   couple of controls this should be a Drawer. */
@media (pointer: coarse) {
  .ds-popover { max-block-size: 40vh; }
}

Component API

Popover

PropTypeDefaultDescription
trigger*(props: { open: boolean; toggle: () => void }) => ReactNode—A render prop, so the trigger owns aria-expanded and aria-haspopup itself.
children*ReactNode—Interactive content. Commands with arrow keys belong in a Menu instead.
side'top' | 'bottom''bottom'Preferred side. Flips automatically when it would be clipped. Left and right are deliberately absent — a horizontally anchored panel has nowhere to go on a narrow viewport.
align'start' | 'center' | 'end''start'Alignment along the cross-axis, shifted as needed to stay in the viewport.
widthstring'auto'Sized by content. Never matched to the trigger.
openOn'click' | 'hover''click'Hover adds the 500ms and 300ms intent delays and opens on focus too.

Notes

Professional tips

  • Show the applied count on the trigger — "Filters (2)" — so the state is visible without opening the panel.
  • Apply changes live where they are cheap to reverse, and add an explicit Apply only when the change is expensive. An Apply button on a column toggle is friction for nothing.
  • Keep the trigger visually active while the panel is open. Without it, the user loses track of which control produced the panel.
  • Close on scroll only when the anchor leaves the viewport. Closing on any scroll makes the panel feel fragile.
  • For coach marks, allow permanent dismissal and never show more than about three in a sequence.

Performance

  • Do not mount the panel until it opens. A table row with a popover per row is otherwise dozens of hidden panels of layout work.
  • Compute placement on open and on resize, not on every scroll frame. Anchoring maths in a scroll listener is the classic cause of jank.
  • Share one panel instance across a list and re-point it at the active anchor.
  • Animate transform and opacity only. Animating width or height re-lays-out the content and the controls visibly jump.

Common mistakes

  • aria-haspopup="menu" on a panel with no arrow-key navigation.
  • aria-expanded hardcoded to false, so the open state is never announced.
  • Focus dropped to the body on close instead of returning to the trigger.
  • A long form inside, lost to an accidental outside click.
  • A hover card with no delays, flickering and unreachable.
  • A hover card with no focus trigger, invisible to keyboard users.
  • Nested popovers, with two dismiss layers users cannot tell apart.
  • Matching the panel width to the trigger, producing a 32px-wide panel from an icon button.

Real-world recommendations

  • Filter popovers are the highest-traffic instance in most products. Showing the active count on the trigger is worth more than anything inside the panel.
  • Hover cards work well for people and repositories, and badly for anything the user has to read carefully — the delay tax is only worth paying for a glance.
  • On touch, most popovers are better as bottom drawers. The interaction is the same, the reach is far better, and dismissal is a familiar gesture.
  • If a popover is growing a header, a footer and a scroll region, it has become a dialog. Promote it rather than continuing to anchor it.