📖 Developer Guide

How to Build a Popover in React — and Why It Is Not a Tooltip

A popover is the component teams build by accident. It starts as a tooltip that needed a link inside it, or a modal that felt too heavy, and it ends up with a tooltip's ARIA, a modal's focus trap and a dismissal model that belongs to neither. This guide draws the line between the three, then builds the real thing in React on the native popover attribute — and is honest about what stays hard.

📅 Updated September 2026 ⏱ 12 min read ✍️ By Kompassify
A trigger button with a popover panel anchored beneath it, labelled with the four things that make it a popover - top layer, light dismiss, focus moves in, and the page stays interactive

Nobody sets out to build a popover. It arrives sideways: a tooltip that needed a link inside it, a dropdown that outgrew a <select>, a modal that felt far too heavy for choosing a date. So the tooltip grows a button, or the modal loses its backdrop, and what ships is a component with a tooltip's ARIA and a modal's focus trap, doing a third job neither was designed for.

The symptoms are consistent. Keyboard users cannot reach the buttons inside it, because role="tooltip" told the browser there would not be any. Or the whole page goes inert while somebody picks a filter. Or it opens fine in Storybook and renders underneath the sticky header in production.

All three are the same root cause: a popover was built out of the wrong parts. This guide starts by separating the three components properly, then builds the real one in React — on the native popover attribute, with anchor positioning and the focus behaviour that belongs to it.

Key Takeaways

  • Three components, three focus models. Tooltip: focus never enters. Modal: focus is trapped. Popover: focus moves in and comes back.
  • popover="auto" is most of the work. Top layer, light dismiss, Escape, and sibling popovers close each other.
  • aria-expanded on the trigger is the required attribute. aria-haspopup is only correct for menus, listboxes and dialogs.
  • Never role="tooltip" on something with buttons in it. That one attribute is what makes the content unreachable.
  • The page stays live. A popover that makes the background inert is a modal wearing the wrong CSS.
  • A UI popover is not an onboarding popover. Same geometry, entirely different system around it.

Popover, Tooltip, Modal: Three Different Components

Before writing anything, settle which one you are building. Three questions decide it, and the answers cascade into every other choice.

Tooltip Popover Modal
Opens on Hover and focus Click, or Enter on the trigger Click, or a product decision
Interactive content Never Yes — that is the point Yes
Focus Never enters it Moves in, returns to the trigger Trapped inside until it closes
The page behind Fully usable Fully usable Inert
Closing Pointer leaves, blur, Escape Outside click, Escape, a choice made An explicit decision
ARIA on the trigger aria-describedby aria-expanded Nothing special
Typical use Icon button labels, truncated cells Filters, date pickers, account menus, share panels Confirmations, full edit forms

The row that decides everything is focus. If content inside needs to be clicked, focus has to be able to reach it, which rules out a tooltip. If the rest of the page should keep working, focus must not be trapped, which rules out a modal. A popover is exactly the component that sits between those two constraints — and its behaviour follows from them rather than from taste.

Where the keyboard goes — the difference that matters TOOLTIP trigger focus stays here text only · never focusable POPOVER trigger buttons inside returns MODAL focus cycles in here only the rest of the page is inert nothing else can be reached Pick the wrong one and the bug report is “I cannot click the button” or “the page froze”.

Content decides focus; focus decides which component you are actually building.


1. The Native popover Attribute

The single highest-leverage line in a modern popover is an attribute. Adding popover="auto" to an element buys four behaviours that hand-rolled versions spend a sprint on:

The one thing to understand before using it is the difference between the two modes:

popover="auto" popover="manual"
Outside click closes itYesNo
Escape closes itYesNo
Closes other popoversYes, non-ancestorsNo — several can be open at once
Use forMenus, filters, pickers — the defaultToasts and inline panels the user closes deliberately
manual is not "I will handle dismissal myself". It turns off Escape as well as the outside click, and a panel that cannot be dismissed with the keyboard is an accessibility defect. If you pick manual, you are now responsible for a key handler — and in almost every case what you actually wanted was auto with a beforetoggle guard.

2. Driving It From React Without Two Sources of Truth

Popovers are imperative: React has no declarative prop for them, so you call showPopover() and hidePopover(). The trap is that the browser can also close the popover on its own — an outside click, Escape, another popover opening — and if React's state does not hear about it, the next click on the trigger appears to do nothing, because state still says open.

The toggle event is what keeps the two honest. Let the DOM be the source of truth for is it open, and mirror it into state for rendering:

usePopover.ts
import { useCallback, useEffect, useRef, useState, useId } from 'react' export function usePopover() { const panelRef = useRef<HTMLDivElement>(null) const triggerRef = useRef<HTMLButtonElement>(null) const [open, setOpen] = useState(false) const id = useId() useEffect(() => { const el = panelRef.current if (!el) return // fires for EVERY state change, including light dismiss const onToggle = (e: ToggleEvent) => { const isOpen = e.newState === 'open' setOpen(isOpen) if (isOpen) { // focus the first control, or the panel itself const first = el.querySelector<HTMLElement>('[data-autofocus]') (first ?? el).focus({ preventScroll: true }) } else if (el.contains(document.activeElement)) { // only reclaim focus if it is still inside the closing panel; // a click on some other control should keep ITS focus triggerRef.current?.focus({ preventScroll: true }) } } el.addEventListener('toggle', onToggle as EventListener) return () => el.removeEventListener('toggle', onToggle as EventListener) }, []) const toggle = useCallback(() => panelRef.current?.togglePopover(), []) return { id, open, toggle, panelRef, triggerRef } }
FilterPopover.tsx
export function FilterPopover({ children }) { const { id, open, toggle, panelRef, triggerRef } = usePopover() return ( <> <button ref={triggerRef} onClick={toggle} aria-expanded={open} // the required attribute aria-controls={id} style={{ anchorName: '--filters' } as React.CSSProperties} > Filters </button> <div ref={panelRef} id={id} popover="auto" // top layer + light dismiss tabIndex={-1} // focusable as a fallback target aria-label="Filter results" className="popover-panel" > {children} </div> </> ) }

The conditional focus return is the detail worth stealing. Blindly focusing the trigger on every close steals focus from wherever the user actually clicked — open a filter panel, click a button elsewhere on the page, and focus snaps backwards to the filter button. Only reclaim it when focus was still inside the panel that is closing.


3. Positioning: Anchor It, Do Not Measure It

The top layer solves stacking; it does not place anything. The popover renders in the middle of the viewport until you position it, and there are two honest options.

1. CSS anchor positioning — no JavaScript at all

Name the trigger, point the popover at that name, choose a side, and give the browser a list of fallbacks for when the chosen side does not fit:

popover.css
@supports (anchor-name: --x) { .popover-panel { position: fixed; position-anchor: --filters; /* matches anchorName on the button */ position-area: block-end span-inline-end; margin-block-start: 6px; position-try-fallbacks: block-start span-inline-end, block-end span-inline-start; max-block-size: 60vh; overflow: auto; } } /* fallback for engines without anchor positioning */ @supports not (anchor-name: --x) { .popover-panel { position: fixed; inset: auto; } /* placed from JS measurement */ }

It costs nothing at runtime, keeps position through scrolling for free, and flips on its own. The catch in 2026 is that engine support is still uneven, so the @supports guard and a measured fallback are not optional if your users are not all on the same browser.

2. A positioning library — one behaviour everywhere, today

The alternative measures the trigger with getBoundingClientRect, computes a placement with collision detection, and re-runs on scroll and resize. It is more code and more work per frame, and it behaves identically in every browser you support. That predictability is usually why teams with a broad browser matrix still choose it.

What is not a real option is hand-rolling the geometry. Flip, shift along the cross axis, arrow placement, nested scroll containers, and staying attached while an ancestor scrolls add up to a genuinely hard problem — the same conclusion the React tooltip guide reaches from the other direction.


4. ARIA and Keyboard: Less Than You Think, But Exactly That

A popover needs far less ARIA than a modal, and the mistakes are mostly additions rather than omissions.

If the popover contains a genuine menu — a list of commands rather than a form — then the keyboard contract is larger: arrow keys move between items, Home and End jump to the ends, typing a letter jumps to a matching item, and only one item is in the tab order at a time. That is a different component with a different name, and it is worth being deliberate about which one you are shipping.

A panel anchored to a control in a product interface, with the rest of the page still visible and usable

Anchored to a control, above everything, and the page behind it still works.


What Stays Hard After It Works

Problem Why it is hard What usually works
Popover inside a dialog Both want the top layer, and Escape is ambiguous — which one should close? The top layer stacks by open order, so the popover is above and closes first. Verify it, because a portal-based popover inside a native dialog gets this backwards.
Nested popovers A submenu inside a menu closes its parent, because they are siblings in the top layer Nest the markup, or wire the child's popovertarget from inside the parent so the browser treats it as a descendant
Anchors in virtualised lists The trigger unmounts while the panel is open, leaving it anchored to nothing Close on trigger unmount, or hoist a single popover to the root and drive its content from the active row
Forms inside popovers Light dismissal silently discards half-typed input Guard beforetoggle when the form is dirty — or accept that a form probably wanted a dialog
Mobile An anchored panel and a virtual keyboard cannot share a small screen Promote to a bottom sheet below a breakpoint; anchoring is a pointer-sized-screen idea

A UI Popover Is Not an Onboarding Popover

The geometry is identical, so the two get conflated constantly. Everything around them differs.

UI popover Onboarding popover
Who opens it The user, by clicking the trigger The product, because of who the user is and what they have not done
Audience Everyone, identically A segment — a role, a plan, an unactivated cohort
State it needs None beyond the interaction Per-user and persisted: seen, dismissed, completed
Anchored to Its own trigger, in the same component An element somewhere else in the app, found by selector at runtime
Lives alone? Yes No — usually one step of a sequence, with a resume point
Measured by Nothing; it opened or it did not View rate, completion rate, and the adoption metric behind it
Who owns it Front-end engineering Product, onboarding or customer success

The second column is where the work explodes. Anchoring to an element in a part of the app the popover component knows nothing about, surviving that element moving or being renamed, remembering per user that it was dismissed, sequencing it with four others — that is a system, not a component, and it changes every time someone edits a sentence. It is the reason onboarding tooltips and hotspots are usually configured rather than coded, and the full version of that argument is our build vs buy analysis.


Build It, or Configure It?

Build Product surface popovers

Filters, date pickers, account menus, share panels, column choosers. Part of the interface, shipped with the feature, owned by the design system.

Configure Guidance and adoption

"Try the new filter", "your trial ends Friday", anything pointed at a segment or a moment. Content that changes weekly should not need a deploy.

Never Anything essential

If the user cannot finish the task without what is inside, it does not belong behind a click on a small target. Put it on the page.

Kompassify covers the middle column without touching the React codebase: popovers, tooltips, hotspots, checklists and tours are pointed at elements in your live product from a visual editor, targeted by segment, and they report their own completion — so a change of wording is an edit, not a release. Our guide to building a product tour in React walks through the same trade-off for multi-step flows, and the React modal guide covers the heavier component in the same family.

Configuring an anchored in-app message visually on a live React product instead of building a popover component for guidance content

Same anchor problem, same top-layer problem — solved once, outside the release cycle.

Keep the component. Skip the content pipeline.

Kompassify lets product and onboarding teams add popovers, tooltips, checklists and guided tours to a React app without shipping a release for every copy change — targeted by segment, with adoption data on each one. Free up to 100 monthly active users, plans from $129/month, GDPR-compliant and EU-hosted.

Start for free →

A Pre-Ship Checklist

Before the popover goes into the design system

  • It is a popover, not a tooltip with buttons or a modal without a backdrop — checked against the focus table
  • popover="auto", so it is in the top layer and light-dismisses
  • aria-expanded on the trigger, kept in sync through the toggle event
  • No role="tooltip", and no aria-modal
  • aria-haspopup only if the content really is a menu, listbox, tree, grid or dialog
  • Focus moves into the panel on open
  • Focus returns to the trigger on close only when it was still inside the panel
  • Escape closes it, from anywhere inside
  • Positioned by anchor positioning or a library — with a fallback path, never hand-rolled geometry
  • Flips at every viewport edge, and scrolls internally instead of overflowing
  • Behaves when the trigger unmounts while it is open
  • Becomes a sheet, not an anchored panel, on a small screen

The One-Sentence Version

A popover is the component where focus moves in and the page stays alive — build it on popover="auto" for the top layer and light dismissal, keep React in sync through the toggle event, anchor it in CSS rather than by hand, and keep onboarding content out of the component entirely.

Frequently Asked Questions

What is the difference between a popover, a tooltip and a modal?

Three axes separate them: who opens it, whether the page behind stays usable, and where focus goes. A tooltip opens on hover or focus, contains no interactive content, and focus never enters it — it only describes the control it is attached to. A modal opens on click, makes the rest of the page inert, traps focus inside itself, and demands a decision before anything else can happen. A popover opens on click, contains interactive content, moves focus into itself, and leaves the rest of the page alive — clicking outside simply closes it. Building a popover with a tooltip's ARIA leaves its buttons unreachable by keyboard; building one with a modal's focus trap makes a filter panel feel like a legal agreement.

Should I use the HTML popover attribute in React?

Yes, for almost every popover. Adding popover="auto" to an element gives you four behaviours the hand-rolled version has to earn: the element is promoted to the browser's top layer, so no z-index competes with it and no ancestor overflow clips it; it light-dismisses on an outside click and on Escape; it closes any sibling popover already open, which is exactly what a menu bar needs; and it fires beforetoggle and toggle events you can hook. React does not manage it declaratively — you still call showPopover() and hidePopover() through a ref, or point a popovertarget button at it — so the integration work is keeping your state and the DOM's state in sync, which the toggle event makes straightforward.

How do you position a React popover next to its trigger?

There are two routes in 2026. CSS anchor positioning is the native one: give the trigger an anchor-name, give the popover a position-anchor and a position-area, and add position-try-fallbacks so the browser flips it when there is no room. It costs no JavaScript, survives scrolling for free, and needs a fallback path in browsers that have not shipped it — wrap the rules in @supports (anchor-name: --x) and fall back to a measured position. The other route is a positioning library that measures the trigger with getBoundingClientRect and recalculates on scroll and resize. Use it when you need a single implementation across every browser today, or when your placement logic is more complex than flip-and-shift. Either way, do not hand-roll the geometry: collision detection with nested scroll containers is a genuinely hard problem.

What ARIA does a popover need?

Less than people think, and different from a tooltip's. The trigger is a <button> carrying aria-expanded, toggled between true and false, so assistive technology announces the state — that single attribute does most of the work. Add aria-haspopup only when the content is a menu, a listbox, a tree, a grid or a dialog, with the matching value; on a plain container of controls it is misleading. Do not put role="tooltip" on it, because a tooltip is not supposed to contain focusable content. Do not add aria-modal, because the page behind a popover is still live. If the panel has a visible heading, aria-labelledby pointing at it gives the popover a name; if it does not, aria-label on the panel serves the same purpose.

Why does my popover render behind other elements?

Because it is not in the top layer, and no z-index will put it there. Any ancestor with a transform, filter, backdrop-filter, will-change, contain or a non-auto opacity creates a stacking context, and a child cannot escape it however large its z-index; any ancestor with overflow: hidden, auto or scroll will crop it. The two real fixes are to add the popover attribute, which promotes the element to the top layer above every stacking context, or to render it through createPortal into document.body so it has no clipping ancestors left. If neither is possible — a table cell popover inside a virtualised list, for instance — the remaining option is to render a single popover at the root and drive its content and position from whichever cell is active.

What is the difference between a UI popover and an onboarding popover?

A UI popover is user-invoked and stateless: someone clicked a filter button, a panel opened next to it, and it says the same thing to everyone. An onboarding popover is product-invoked and stateful: it appears because a particular user has not used a particular feature yet, it is targeted by segment, it usually belongs to a sequence, and it needs per-user dismissal state so it never appears twice. The positioning code is identical; the surrounding system is not. The hard parts of the second are targeting, persistence, sequencing and being able to rewrite the copy without a deploy — which is why product teams typically configure that layer in a tool rather than shipping it through the front-end release cycle.