Home / Articles / Replacing Tooltip Libraries with the Popover API and CSS Anchor Positioning

This article is published in English.

Replacing Tooltip Libraries with the Popover API and CSS Anchor Positioning

Why tooltips needed Popper and Floating UI, which native feature now solves stacking, positioning and dismissal, and when a JavaScript library is still worth it.

2090 words

Showing a line of text next to a button looks trivial, yet Popper.js, Floating UI and a crowd of wrapper packages exist because it never was. A tooltip is really three separate problems layered on top of one another, and until recently the platform offered a declarative answer to none of them. This guide takes the problems apart, shows what each one cost in shipped JavaScript, and maps them onto the two browser features that now cover the common cases: the Popover API and CSS Anchor Positioning. By the end you will know which parts of a tooltip library you can delete and which parts you may still need.

Three problems hiding inside one tooltip

Each of these is a long-standing, well-understood limitation of how the browser behaved before the new specs arrived:

  • Stacking. Will the tooltip paint above everything else, or will some ancestor's overflow: hidden clip it?
  • Positioning. Does the tooltip know where its trigger sits on screen, and does it follow that position during scrolling and resizing?
  • Dismissal. Does it close on its own when the user clicks somewhere else or presses Escape?

For most of the web's history, every project that needed a tooltip, a dropdown or an autocomplete list solved all three in JavaScript. The fixes are different for each, and treating them as one problem is precisely how a small UI detail turned into a dependency. So it pays to look at them one by one.

Stacking: why z-index cannot escape its context

Every element belongs to a stacking context, which decides what paints over what. z-index orders elements within a single stacking context; it cannot lift an element out of the context it belongs to.

Put the tooltip inside a container with overflow: hidden, or inside a modal that creates its own stacking context, and no value, not even 999999, will make it appear above that boundary. The tooltip is confined by its ancestors' rendering rules, and z-index has no reach beyond them.

The traditional escape hatch was a portal: mount the tooltip's markup at a different point in the document, usually appended to <body>, so it no longer inherits the parent's clipping and stacking. React's createPortal exists largely because of this. It is less a React feature than a workaround for something CSS could not express.

Positioning: absolute positioning only understands ancestors

position: absolute places an element relative to its nearest positioned ancestor, meaning the closest element up the tree whose position is relative, absolute, fixed or sticky. The key word is ancestor: the reference has to sit in the same branch of the DOM, somewhere above the tooltip.

As soon as the tooltip and its trigger are siblings, or the tooltip has been portaled to <body> to solve stacking, the trigger is no longer an ancestor. CSS had no way to say "position this relative to that unrelated element over there." The weakness was never CSS positioning in general; it was the absence of any positioning relationship that ignores document structure.

Libraries filled the gap by measuring. They call getBoundingClientRect(), to get the trigger's viewport-relative box, derive coordinates for the tooltip, and redo that calculation on every scroll and resize because the numbers keep changing. That continuously running measure-and-place loop is most of what a positioning library does at runtime.

Dismissal: behavior that markup could not describe

Before the Popover API, HTML and CSS had no notion of "close when the user clicks outside or presses Escape." It was entirely scripted: a click listener on document checking whether the event target falls outside the tooltip, a keydown listener waiting for Escape, and cleanup for both when the component unmounts so they do not leak. Unlike the first two problems, this one has no geometry to it. It is pure behavior, but it is still a third chunk of runtime code the browser did not provide.

Popovers versus modals

It helps to pin down terminology before looking at syntax. A popover is anything that renders above the rest of the page, is positioned relative to a trigger, and dismisses itself on an outside click or Escape. Tooltips, dropdown menus, autocomplete lists and context menus are all popovers with different styling and the same three mechanical problems.

A modal shares the stacking problem but is not a popover, because it blocks. While a modal is open, the content behind it is inert: users cannot tab into it, click it or scroll it, and there is usually a backdrop with focus held inside the modal until it closes. Think of a "confirm delete" prompt: nothing else on the page is usable until it is answered. A popover blocks nothing. The page stays fully interactive, and the popover simply closes when the user moves on.

The specs encode this distinction directly:

  • popover="auto" gives light dismiss and no blocking: it closes on outside click or Escape and leaves focus free. Tooltips and dropdowns fall into this category.
  • popover="manual" stays open until your script closes it, with no light dismiss, which suits something like a persistent notification toast.
  • A <dialog> opened with .showModal() is the blocking variant: it goes into the top layer, gets a backdrop, contains focus and renders everything behind it inert.
  • Opening that <dialog> via .show() instead yields a non-blocking element and behaves much like a popover.

What the JavaScript approach costs

The following gzipped sizes were taken from npm and cover only the libraries themselves:

popper.js (v1, now deprecated)              7.1 KB
Tippy.js (bundles @popperjs/core)          14.1 KB
Floating UI, vanilla (@floating-ui/dom)     8.1 KB
Floating UI, React bindings                30.1 KB
react-tooltip (@floating-ui/dom + clsx)    14.1 KB

These numbers exclude everything you add on top: configuration, the wrapper component, arrow and theme CSS. That is the baseline cost before any of your own logic, and a project that combines, say, a tooltip package with a separate dropdown package pays it twice.

None of this reflects poor engineering. Floating UI in particular is carefully built; its core job is getting flip-on-overflow right across every browser's quirks. The expense came from having to solve three unrelated problems together in JavaScript because the platform offered nothing else.

The Popover API handles stacking and dismissal

Two separate specs replaced the library, and they do not divide the work the way you might expect. The popover attribute takes care of stacking and dismissal at once, with almost no script.

<button popovertarget="my-tooltip">Hover me</button>
<div id="my-tooltip" popover="auto">
  This is the tooltip content.
</div>

The popovertarget attribute wires the button to the element with the matching id. With popover="auto", the element is moved into the browser's top layer when it opens, which is exactly the escape from overflow: hidden and stacking contexts described earlier, and it gets light dismiss for free: outside clicks and Escape close it without any listener code.

One correction to the markup's wording: popovertarget toggles on activation, that is a click, tap or keyboard press, not on hover. A true hover tooltip still needs a small amount of script to call showPopover() and hidePopover() on pointer and focus events, or a newer declarative mechanism once your target browsers support it. Even so, stacking and dismissal no longer require a library. Positioning is the remaining gap, and it belongs to a different spec.

Anchor Positioning links elements by name

CSS Anchor Positioning addresses the one problem the Popover API leaves alone. It lets any two elements in the document refer to each other by name rather than through parent and child structure.

.trigger {
  anchor-name: --my-anchor;
}

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  top: anchor(--my-anchor bottom);
  left: anchor(--my-anchor left);
}

anchor-name registers the trigger under a dashed identifier, the same syntax custom properties use. position-anchor on the tooltip points at that name, and the anchor() function reads a particular edge of the anchor (top, right, bottom, left or center) so the tooltip can align itself to it.

Crucially, neither element has to contain the other. The browser now does natively what getBoundingClientRect() used to compute by hand on every scroll frame. When you combine this with a popover, remember that the user agent stylesheet gives [popover] elements inset: 0 and margin: auto to center them; if the tooltip ignores your anchor offsets, resetting those properties is usually the fix.

Flipping on overflow without a scroll listener

The piece of a positioning library that holds most of its logic is overflow handling: noticing that the tooltip is about to run off the viewport and choosing another placement first. Anchor positioning covers this with position-try-fallbacks.

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  position-area: top center;
  position-try-fallbacks: flip-block, flip-inline;
}

Here position-area: top center sets the default placement, and position-try-fallbacks lists alternatives the browser tries in order when that placement would overflow its containing block or the viewport. flip-block mirrors the tooltip across the block axis, so top becomes bottom, and flip-inline mirrors it across the inline axis, so left becomes right. The browser reevaluates this during layout, with no scroll handler and no main-thread script detecting the overflow.

When a simple mirror is not enough, the @position-try at-rule lets you define named fallback placements, each a small block of positioning declarations the browser can cycle through.

@position-try --below {
  position-area: bottom center;
  margin-top: 8px;
}

@position-try --above {
  position-area: top center;
  margin-bottom: 8px;
}

.tooltip {
  position-anchor: --my-anchor;
  position-try-fallbacks: --above, --below;
}

This is the same decision Floating UI makes in JavaScript on every scroll event, declared up front as data that the layout engine evaluates. Note that the .tooltip rule in this snippet assumes the element is already absolutely or fixed positioned, as in the earlier examples; anchor positioning has no effect on statically positioned elements.

When a positioning library is still justified

For a tooltip, a simple dropdown or an autocomplete list, the native combination is now a sensible default. The library's role has shrunk, though it has not disappeared.

Browser support is the first constraint. Chromium browsers have supported anchor positioning since version 125, and @position-try reached Baseline later than anchor() itself. Support in Safari and Firefox arrived afterwards, and figures quoted around the web differ, so check a current compatibility table rather than trusting any single list of versions. Where support is missing, there is no graceful CSS-only degradation: a browser that does not understand anchor() simply fails to position the element. If you must support older Safari releases or mobile browsers on older engines, keep a fallback path; our guide to shipping modern CSS safely covers feature detection and progressive enhancement for anchors.

The second case is placement rules that go beyond mirroring on overflow: a floating panel containing a virtualized list, collisions checked against multiple boundaries simultaneously, or placement driven by application data rather than layout. Script can react to whatever state the application holds, while a fixed CSS fallback list only knows about layout.

For the ordinary case, which covers most projects, spending 8 to 30 KB of JavaScript on work the browser now performs itself is hard to justify.

Key takeaways

  • A tooltip is three problems: stacking, positioning and dismissal. Libraries existed because all three had to be solved in script.
  • The Popover API solves stacking through the top layer and dismissal through light dismiss; popover="manual" and <dialog> with .showModal() cover the persistent and blocking cases.
  • CSS Anchor Positioning solves placement without an ancestor relationship, and position-try-fallbacks plus @position-try replace the flip-on-overflow logic that dominated library runtimes.
  • Hover behavior and browser support are the two gaps to plan for; check compatibility data for your audience before removing a library.
  • Keep Floating UI where you need legacy browser support or data-driven positioning, and default to the platform everywhere else. The libraries did not get worse; the browser finally took over a job it had left to JavaScript for more than a decade.