Skip to main content
Web / React · WCAG 2.2 AA

Popover

A non-modal overlay anchored to its trigger; uses role="dialog" with an accessible name, moves focus in on open and restores it on close, but does not trap focus or inert the background.

Selection

The criteria an agent checks before retrieving this component.

Use when

  • Use when an overlay anchored to a trigger presents content or a task without blocking the page (e.g., an editor popover, a composer, a rich panel with interactive controls).
  • Use when the user can keep interacting with the rest of the page while the overlay is open.
  • Use when focus should move into the overlay on open but not be trapped there.

Try a different component when

  • Do not use when the user must complete or dismiss the overlay before returning to the page (use dialog.basic).
  • Do not use when a control simply shows and hides inline content (use disclosure.basic).
  • Do not use when the overlay is a menu of commands (use menu.basic).
  • Do not use when the message is a brief, non-interactive status update (use toast.basic).
  • Do not use when the content is a plain-text supplementary hint (use tooltip.basic).

Must Haves

Non-negotiable structure. Every generated instance must satisfy these rules.

  • The popover surface uses role="dialog", or a native <dialog> element shown non-modally (rendered in the DOM or opened with .show(), never .showModal()).
  • The popover has an accessible name via aria-labelledby (preferred), referencing a visible title element (e.g., <h2 id="...">), or aria-label.
  • Focus moves into the popover on open, landing on the popover container or its first interactive control.
  • Esc closes the popover, regardless of where focus currently sits, since focus is not trapped and may have moved into the page.
  • Focus is restored to the invoking element on close.
    • Capture the invoking element at open time, and restore focus with requestAnimationFrame after the popover is unmounted.
  • The popover does not trap focus. Tab and Shift+Tab move out of the popover into the rest of the page, which stays interactive.
  • The popover does not set aria-modal="true" and does not apply inert to the background.
  • Provide a visible close control. Use a native <button> (preferred), or role="button" only when a native button cannot be used.
    • If role="button" is used instead of a native <button>, add tabindex="0" and keyboard support for Enter and Space, ensuring Space prevents page scrolling while activating the control.
  • The close control has an accessible name that describes its purpose or action (e.g., aria-label="Close").
  • Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the trigger, the close control, and any focusable content within the popover.

Donts

Avoid these accessibility and UX barriers.

  • Do not set aria-modal="true" on a popover; it falsely tells assistive technology the background is inert.
  • Do not trap focus within the popover.
  • Do not apply inert to, or otherwise block, the background content.
  • Do not omit focus restoration; closing the popover must return focus to the element that invoked it.
  • Do not bind Esc only to a container-level handler on the popover surface; once focus leaves the untrapped popover, that handler stops firing and Esc no longer closes.

Customizable

Alternatives and options that give the AI agent some room to move.

  • Delivery is at the engineer's discretion as long as the non-modal contract above holds. Acceptable options:
    • A native <dialog> element shown non-modally.
    • The popover attribute, which adds light dismiss on outside click and Esc for free.
    • A <div role="dialog"> with explicit positioning.
  • Positioning is at the engineer's discretion. A popover is normally anchored to its trigger (CSS anchor positioning, the popover attribute's anchoring, or a positioning library), but a fixed-position panel is acceptable as long as the relationship to the trigger stays clear and focus still moves in on open.
  • Whether an outside click also dismisses the popover is optional. The popover attribute provides it automatically; a role="dialog" div may add it via an outside-pointer handler, or omit it.
  • Initial focus may land on the popover container (tabindex="-1") so the accessible name is announced before the user Tabs to the first control, or on the first interactive control when the user's next action is to type or select. Either is acceptable.

Golden Pattern

The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.

JSX
"use client";

export function Popover() {
const [open, setOpen] = useState(false);
const openerRef = useRef(null);
const popoverRef = useRef(null);
const titleId = useId();

const openPopover = () => {
// Capture the opener at the moment we open, so focus can be restored on close.
openerRef.current = document.activeElement;
setOpen(true);
};

const closePopover = () => {
setOpen(false);
// Defer until the popover has unmounted and the opener is focusable again.
const opener = openerRef.current;
requestAnimationFrame(() => opener?.focus());
};

// Move focus into the popover on open. No focus trap: Tab leaves the popover
// into the rest of the page, which stays interactive (no inert, no aria-modal).
useEffect(() => {
if (open) popoverRef.current?.focus();
}, [open]);

// Esc must close from anywhere, because focus is not trapped and may sit in
// the page. A container-level onKeyDown would miss those keystrokes, so bind
// Esc at the document level while open.
useEffect(() => {
if (!open) return;
const onKeyDown = (e) => {
if (e.key === "Escape") closePopover();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);

return (
<div>
<button type="button" onClick={openPopover}>
Edit note
</button>

{/* The rest of the page stays interactive while the popover is open. */}
<button
type="button"
onClick={() => alert("Background control still works")}
>
Background action
</button>

{open
? createPortal(
<div
ref={popoverRef}
role="dialog"
aria-labelledby={titleId}
tabIndex={-1}
style={{
position: "fixed",
top: 80,
right: 24,
width: "min(360px, 100%)",
border: "1px solid rgba(0,0,0,0.3)",
borderRadius: 12,
padding: 16,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<h2 id={titleId} style={{ margin: 0 }}>
Edit note
</h2>
<button type="button" onClick={closePopover} aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>

<label>
Note
<textarea defaultValue={DEFAULT_NOTES[0]} rows={4} />
</label>

<button type="button" onClick={closePopover}>
Save
</button>
</div>,
document.body
)
: null}
</div>
);
}

const DEFAULT_NOTES = [
"Reminder: finish the S2 finale review before it leaves the service on Friday.",
];

Acceptance Checks

The component’s test spec — an optional body of checks for verification.

On open

  • Focus moves into the popover.
  • The popover is announced with its accessible name.
  • Background content remains reachable and interactive.

While open

  • Esc closes the popover, including when focus has moved out of the popover into the page.
  • Tab and Shift+Tab move focus out of the popover into the page, confirming focus is not trapped.
  • The trigger, the close control, and any focusable content within the popover show a visible focus indicator (e.g., a 2px solid outline) when focused.

On close

  • Focus returns to the invoking element.