EmpireUI
Get Pro
← Blog7 min read#popover-api#html#react

Native Popover API: Tooltips and Menus Without JavaScript

The native Popover API ships tooltips, dropdowns, and menus in pure HTML — no JS state management needed. Here's how to use it in React and Tailwind today.

A developer's monitor showing HTML and CSS code for an interactive popover component with floating menu elements

The Browser Already Does This

Honestly, we've been writing JavaScript to solve a problem the browser has had built-in answers for years. Tooltips. Dropdown menus. Context panels. You'd reach for Floating UI or Popper.js, manage open/close state in React, wire up click-outside listeners, handle focus trapping, and then deal with z-index wars on every single project. It gets old fast.

The Popover API shipped in Chrome 114, Firefox 125, and Safari 17. As of late 2026 it has over 92% global browser support. It's not experimental. It's not a proposal. It's here.

What it gives you: a popover HTML attribute, a popovertarget attribute on buttons, and a CSS ::backdrop pseudo-element — all wired together by the browser with zero JavaScript. The browser handles the top-layer stacking context, dismissal on Escape, and click-outside behavior. That's the entire feature set of most tooltip libraries, handled natively.

This article walks through the Popover API in real React and Tailwind v4.0.2 code, covers the edge cases, and shows you where JavaScript is still useful — without pretending you need it for the basics.

How the Popover Attribute Works

The mental model is simple. Add popover to any element and it starts hidden. Add popovertarget to a button pointing at that element's ID and the browser wires the toggle behavior automatically. No useState. No event handler. Just attributes.

There are two flavors: popover="auto" and popover="manual". Auto popovers dismiss when you click outside or press Escape — that's what you want for tooltips and menus. Manual popovers only respond to explicit JavaScript calls like el.showPopover() or el.hidePopover(). Use manual when you need programmatic control, like a notification drawer that should only close via a dismiss button.

The popovertargetaction attribute lets you change the button behavior from toggle (default) to show or hide only. That's useful when you have separate open and close buttons, or when you're building an accessible disclosure pattern where re-clicking the trigger shouldn't close the panel.

One thing that trips people up: popover elements render in the top layer. They escape all overflow: hidden containers, escape stacking contexts, and sit above everything including fixed headers. This is what you always had to fake with portals in React. The browser does it for free now.

Minimal React + Tailwind Implementation

React doesn't need anything special here. You're just passing HTML attributes, and JSX supports all valid HTML attributes. The only friction is that React uses camelCase — so popovertarget stays as-is (it's already lowercase), but check the version you're on since React 19 improved unknown attribute handling significantly.

Here's a working tooltip-style popover with Tailwind v4.0.2 utility classes:

export function HelpTooltip({ text }: { text: string }) {
  return (
    <>
      <button
        type="button"
        popovertarget="help-tip"
        className="w-5 h-5 rounded-full bg-zinc-700 text-white text-xs font-bold hover:bg-zinc-600 transition-colors"
      >
        ?
      </button>

      <div
        id="help-tip"
        popover="auto"
        className={
          "bg-zinc-900 text-zinc-100 text-sm rounded-lg px-3 py-2 "
          + "max-w-xs shadow-xl border border-zinc-700 "
          + "[&::backdrop]:bg-transparent"
        }
      >
        {text}
      </div>
    </>
  );
}

Notice [&::backdrop]:bg-transparent — that Tailwind arbitrary variant targets the ::backdrop pseudo-element. Auto popovers still render a backdrop (invisible by default), but you can style it to add a scrim if needed. And you don't need a portal, z-index: 9999, or any position: fixed hackery. The top layer handles it.

Positioning: Where CSS Anchor Positioning Comes In

Here's the thing: the Popover API doesn't position the popover near its trigger. That's intentional — positioning is a separate concern. By default your popover appears in the center of the viewport. For tooltips you want it anchored near the button.

CSS Anchor Positioning (currently Chrome 125+ with Safari 18 adding support) is the long-term answer. You assign anchor-name: --my-btn on the trigger and position-anchor: --my-btn on the popover, then use inset-area or position-try-fallbacks to place it. It's elegant and we'll see full cross-browser support by mid-2027 based on the current implementation timeline.

In the meantime, the pragmatic approach is a thin JS helper that reads getBoundingClientRect() on show and sets inline top/left on the popover. That's maybe 15 lines of code — far less than a full positioning library — and it handles the 90% case. Compare that to pulling in Floating UI which adds roughly 14KB to your bundle.

For Empire UI components, we layer this as an opt-in hook: if you pass anchor prop, we do the positioning dance; if you don't, you get raw native behavior. That keeps the zero-dependency path clean. If you're doing heavy animations on top of this — say, coordinating with Lottie animations or canvas-based transitions — you'll want that hook anyway since you need the show/hide callbacks.

Accessible Menu Pattern With Popover

Screen readers and keyboard users need more than just open/close behavior. A proper menu needs role="menu" on the popover, role="menuitem" on each option, and arrow-key navigation. The Popover API handles the disclosure part; you're still responsible for the ARIA roles and keyboard handler.

Here's the pattern we use for a command-style dropdown:

function CommandMenu() {
  const menuRef = useRef<HTMLDivElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    const items = menuRef.current?.querySelectorAll('[role="menuitem"]');
    if (!items) return;
    const arr = Array.from(items) as HTMLElement[];
    const current = document.activeElement as HTMLElement;
    const idx = arr.indexOf(current);

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      arr[(idx + 1) % arr.length]?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      arr[(idx - 1 + arr.length) % arr.length]?.focus();
    }
  };

  return (
    <>
      <button
        popovertarget="cmd-menu"
        aria-haspopup="menu"
        className="btn-primary"
      >
        Actions
      </button>

      <div
        id="cmd-menu"
        ref={menuRef}
        popover="auto"
        role="menu"
        onKeyDown={handleKeyDown}
        className="bg-zinc-900 border border-zinc-700 rounded-xl p-1 shadow-2xl min-w-48"
      >
        {['Edit', 'Duplicate', 'Archive', 'Delete'].map((label) => (
          <button
            key={label}
            role="menuitem"
            className="block w-full text-left px-3 py-2 text-sm text-zinc-100 rounded-lg hover:bg-zinc-800 focus:bg-zinc-800 focus:outline-none"
          >
            {label}
          </button>
        ))}
      </div>
    </>
  );
}

The Escape key and click-outside dismissal come free. You're only adding arrow-key navigation on top, which is about 12 lines. The ARIA pattern here follows the WAI-ARIA Menu authoring practice — aria-haspopup="menu" on the trigger, role="menu" on the container. Most JS-heavy menu libraries wrap exactly this, just with a lot more code around it.

Styling the ::backdrop and Transitions

The ::backdrop pseudo-element is one of the more underused parts of this API. For modals you'd set background: rgba(0,0,0,0.5). For popovers you'll usually want background: transparent since you're not trying to lock the user in. But for side panels or slide-over menus that use popover="manual", a subtle rgba(0,0,0,0.3) backdrop can really clean up the UX.

Transitions are trickier. The popover element starts in display: none until shown, which historically meant CSS transitions couldn't run on show. The @starting-style rule (Chrome 117+, Firefox 129+) solves this. You define the initial style for the entering state and the browser animates from there:

#help-tip {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition: opacity 150ms ease, transform 150ms ease,
    display 150ms allow-discrete,
    overlay 150ms allow-discrete;
}

@starting-style {
  #help-tip:popover-open {
    opacity: 0;
    transform: translateY(-6px) scale(0.97);
  }
}

#help-tip:not(:popover-open) {
  opacity: 0;
  transform: translateY(-6px) scale(0.97);
}

The allow-discrete keyword on display and overlay is what enables animating in and out of the top layer. Without it you get an instant snap. The exit animation uses the :not(:popover-open) selector, which targets the closing state. This pattern pairs nicely with the glassmorphism style backgrounds you'd find in what is glassmorphism — frosted glass panels that fade in look genuinely polished without a single animation library.

Where JavaScript Is Still Useful

Don't misread this as "never use JavaScript for popovers." There are real cases where JS earns its place. Programmatic show/hide via el.showPopover() and el.hidePopover() let you trigger popovers from non-button elements, from async events, or from outside the component tree. The toggle event fires on open and close — useful for syncing React state with the native popover state when you need it.

What's the difference between syncing state versus just using native behavior? If the rest of your UI doesn't need to know whether the popover is open, don't bother. If you're conditionally rendering content inside the popover, fetching data on open, or updating a parent component's state on close — then listen to the toggle event and update from there. It's a useEffect with an addEventListener('toggle', ...) call. Ten lines max.

The beforetoggle event (landing in browsers now) fires before the state changes and lets you call event.preventDefault() to cancel the toggle. That's your escape hatch for validation — don't close the menu until the user has confirmed a required field, for instance. For theme toggle patterns, this is how you'd gate the dark-mode switch behind a short animation before actually swapping the class.

One pattern worth calling out: nested popovers. Auto popovers dismiss each other by default — opening one closes others in the same top-layer stack. If you want a submenu to coexist with its parent menu, you have to manage them with popover="manual" and handle the nesting explicitly. The spec has an "ancestor" concept for auto popovers that's still being finalized, so for nested menus, manual is the safer bet right now.

Progressive Enhancement and Fallbacks

Global support is 92%+, but that 8% matters in some markets. The clean fallback strategy is to feature-detect and progressively enhance. If HTMLElement.prototype.popoverTargetElement exists, you're on a supporting browser. If not, you swap in your old JS-driven solution or render a plain visible element.

In a React context you can do this once at the app level:

export const supportsPopover = 
  typeof HTMLElement !== 'undefined' &&
  'popover' in HTMLElement.prototype;

Then conditionally render the native version or a JS fallback. Honestly, for most SaaS dashboards targeting modern browsers, you can skip the fallback entirely — check your analytics and make the call. The developer experience of dropping a library dependency in exchange for a small compatibility note in your README is usually worth it. For deeper architectural decisions on handling CSS compatibility like this, see how we approach Tailwind vs CSS Modules for scoping decisions.

FAQ

Does the Popover API work in React without any special setup?

Yes, but with a small catch. React 18 passes unknown attributes to the DOM, so popover and popovertarget work as JSX attributes. React 19 improved this further with better custom attribute support. Just write them as lowercase HTML attributes in JSX and they work. TypeScript may complain — add a .d.ts declaration to extend JSX.IntrinsicElements if needed.

How do I position a popover near its trigger button?

The Popover API doesn't handle positioning — that's intentional. CSS Anchor Positioning (Chrome 125+, Safari 18+) is the native answer: set anchor-name: --btn on the trigger and position-anchor: --btn on the popover. For broader browser support today, use a small getBoundingClientRect() helper that sets inline top/left styles when the toggle event fires.

What's the difference between popover="auto" and popover="manual"?

Auto popovers dismiss on outside click and Escape, and they close other auto popovers when opened (they're "mutually exclusive" by default in the same top-layer stack). Manual popovers only open/close via explicit JS calls to el.showPopover() and el.hidePopover() — they ignore outside clicks entirely. Use auto for menus and tooltips, manual for drawers and notification panels.

Can I animate a popover opening and closing with CSS?

Yes, using @starting-style (Chrome 117+, Firefox 129+) for entry animations and :not(:popover-open) for exit. You also need transition: display 150ms allow-discrete, overlay 150ms allow-discrete — the allow-discrete keyword is what lets the browser animate discrete properties like display and the top-layer overlay. Without it, transitions don't run.

Is the Popover API accessible by default?

It handles dismissal (Escape key, click-outside) and focus management at the top-layer level. But it doesn't add ARIA roles for you. For a tooltip, add role="tooltip" and aria-describedby pointing to it. For a menu, add role="menu" on the popover, role="menuitem" on items, and implement arrow-key navigation yourself. The browser provides the infrastructure; semantic roles are your responsibility.

Does the Popover API replace Radix UI or Headless UI?

For simple cases — a help tooltip, a user menu, a share panel — yes, it can replace those libraries entirely. For complex patterns like comboboxes, date pickers, or nested submenus with full keyboard navigation specs, the component libraries still do real work. The Popover API is a floor, not a ceiling. Use it as the primitive and build up from there.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Popover Anchor Targeting: CSS Positioning for Native TooltipsContainer Style Queries: CSS Theming Without JavaScriptReact UI Components Complete Reference: 60+ Patterns with CodeBuilding Design Systems That Scale: Engineering Guide 2026