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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
- WidthContent-sized, 12–24rem
Not matched to the trigger. A popover from a 32px icon button is still 15rem wide, because the content decides.
- Max height50vh, then scrolls
Past half the viewport it is a dialog wearing an anchor, and the anchor relationship stops meaning anything.
- Offset6px from the trigger
Close enough to read as attached; far enough that the trigger’s focus ring is not clipped by the panel.
- Padding14px
One step below a card. A popover is transient and does not need the breathing room a permanent surface earns.
- Elevation--shadow-e4
Above the page, below a dialog. A dialog opened from a popover must clearly sit above it.
- CollisionFlip, then shift
Flip to the opposite side first, then slide along the cross-axis. Both preserve the anchor; clipping destroys it.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| offset | Gap from the trigger | |
| padding | Panel padding |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Scale-in entrance | |
| hover intent | Hover-card delays |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Min width | Max width | When to use |
|---|---|---|---|---|---|---|
| Compact | — | 10px | — | 12rem | — | A couple of toggles or a short preview. |
| Default | — | 14px | — | 15rem | 20rem | The default. Filters, display options, a short form. |
| Wide | — | 16px | — | — | 24rem | A hover card with an avatar and metadata, or a two-column set of options. |
| Max height | 50vh | — | — | — | — | 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. |
onClose → triggerRef.current?.focus()aria-haspopup="dialog" aria-expanded="true"Not a checklist to run at the end. These are the requirements the component was built from.
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 / Space | On the trigger, opens the panel and moves focus to its first focusable element. |
| Tab | Cycles within the panel while it is open. It is not a full focus trap, but focus must not escape into the page behind unnoticed. |
| Esc | Closes and returns focus to the trigger. Always. |
| Tab out | From the last element, closes the panel and continues into the page — a popover is not a modal. |
| Arrow keys | Nothing 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.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-haspopup="dialog" | The trigger | Not "menu" unless it genuinely is one. The announced role sets the keyboard expectation. |
| aria-expanded | The trigger | Must track the real state. A hardcoded false is a silent and very common bug. |
| role="dialog" | The panel | With aria-label or aria-labelledby pointing at its heading. An anonymous panel is announced as an unnamed region. |
| aria-controls | The trigger | Points at the panel id, associating the two for assistive tech. |
| aria-modal="false" | The panel | Explicit: content behind stays reachable. That is the difference from a Dialog. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| width | string | '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. |
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.