Type something to search...
CSS Anchor Positioning: Tooltips and Popovers Made Easy

CSS Anchor Positioning: Tooltips and Popovers Made Easy

Tooltips, dropdown menus, date pickers, and context menus all share one annoying problem: they need to appear right next to another element, and stay there. If the trigger is near the edge of the screen, the menu should flip to the other side. If the page scrolls, the tooltip should follow. For more than a decade, that meant reaching for a JavaScript positioning library that measured elements with getBoundingClientRect() and recalculated on every scroll and resize.

CSS anchor positioning moves all of that into the browser. You name one element as an anchor, tell another element to position itself relative to it, and optionally list fallback positions for when space runs out. No measuring, no scroll listeners, no layout thrashing. In this guide, we'll build tooltips and popover menus with anchor positioning, cover the key properties, and look at how to ship it today with sensible fallbacks.

The Core Idea

Anchor positioning has two sides:

  1. The anchor: the element you want to position against, like a button. You give it a name with anchor-name.
  2. The positioned element: an absolutely or fixed positioned element, like a tooltip. You link it to the anchor with position-anchor, then place it with position-area or the anchor() function.

Here's the smallest possible example:

<button class="help-button">?</button>
<div class="help-tip">Your API key is on the Settings page.</div>
.help-button {
  anchor-name: --help;
}

.help-tip {
  position: absolute;
  position-anchor: --help;
  position-area: top;
  margin-bottom: 0.5rem;
}

The tooltip now sits centered above the button, wherever the button is on the page. Anchor names are dashed identifiers, just like custom properties, so they never collide with CSS keywords.

Placing Elements with position-area

position-area is the easiest way to place an anchored element. Picture a 3 by 3 grid centered on the anchor: the anchor is the middle cell, and the eight cells around it are the areas you can place things into.

/* Directly above, centered */
.tip {
  position-area: top;
}

/* Below, centered */
.tip {
  position-area: bottom;
}

/* To the right, vertically centered */
.tip {
  position-area: right;
}

/* Below, aligned to start at the anchor's left edge and extending right */
.menu {
  position-area: bottom span-right;
}

/* Top-left corner cell */
.badge {
  position-area: top left;
}

The span- keywords let an element span two cells. bottom span-right is the classic dropdown placement: below the trigger, left-aligned with it, extending to the right. Logical keywords like block-end span-inline-end work too, and adapt to writing direction.

Use margin on the positioned element to add a gap from the anchor. Because the element sits in a specific area, a bottom margin on a tooltip above the anchor pushes it away.

Fine Control with the anchor() Function

When you need exact edges, use the anchor() function inside inset properties. It returns the position of a given edge of the anchor:

.tooltip {
  position: absolute;
  position-anchor: --help;

  /* The tooltip's bottom edge sits 8px above the anchor's top edge */
  bottom: calc(anchor(top) + 8px);

  /* Center horizontally on the anchor */
  left: anchor(center);
  translate: -50% 0;
}

Accepted values include top, right, bottom, left, center, start, end, and percentages like anchor(25%). There's also a centering shortcut: justify-self: anchor-center (or align-self: anchor-center) centers the element on the anchor without the translate trick.

.tooltip {
  position: absolute;
  position-anchor: --help;
  bottom: anchor(top);
  justify-self: anchor-center;
  margin-bottom: 8px;
}

Matching the Anchor's Size

The anchor-size() function reads the anchor's dimensions. It's perfect for a dropdown that should be at least as wide as its trigger:

.select-menu {
  position: absolute;
  position-anchor: --country-select;
  position-area: bottom span-right;
  min-width: anchor-size(width);
}

Building a Tooltip with Popover

Anchor positioning pairs beautifully with the Popover API. Popovers render in the top layer, so they're never clipped by overflow: hidden or trapped under other elements by stacking contexts, and anchor positioning puts them in the right place.

<button
  type="button"
  class="icon-button"
  popovertarget="share-tip"
  style="anchor-name: --share"
>
  Share
</button>

<div id="share-tip" popover="hint" class="tooltip">
  Copy a public link to this report
</div>
.tooltip {
  position-anchor: --share;
  position-area: top;
  margin: 0 0 0.5rem;

  padding: 0.375rem 0.625rem;
  border: 0;
  border-radius: 0.375rem;
  background: #111827;
  color: #f9fafb;
  font-size: 0.8125rem;
  max-width: 16rem;
}

Popovers are position: fixed by default, which works with anchor positioning. Note the margin reset: the UA stylesheet gives popovers margin: auto for centering, which you need to override so the gap works as intended.

A few notes on this setup:

  • popover="hint" is designed for tooltips: it doesn't close other open auto popovers. It's newer and not in every browser yet; browsers that don't recognize the value treat the element as a manual popover. popover="auto" is a safe choice if you need consistent behavior.
  • When a popover is opened by a button with popovertarget, Chromium treats that button as an implicit anchor, so position-anchor isn't even required. Setting it explicitly is more portable.

Showing a Tooltip on Hover and Focus

Popovers open on click by default. For a hover tooltip, interest invokers (the interestfor attribute) are being developed to open popovers on hover and focus declaratively, but they aren't widely supported yet. Until they are, a few lines of JavaScript work well:

document.querySelectorAll("[data-tooltip]").forEach((trigger) => {
  const tip = document.getElementById(trigger.dataset.tooltip);
  const show = () => tip.showPopover();
  const hide = () => tip.hidePopover();

  trigger.addEventListener("mouseenter", show);
  trigger.addEventListener("mouseleave", hide);
  trigger.addEventListener("focus", show);
  trigger.addEventListener("blur", hide);
});

Pair it with aria-describedby on the trigger pointing to the tooltip's id so screen readers read the hint.

A Pure CSS Hover Tooltip

If you'd rather avoid JavaScript entirely, you can anchor a regular element and show it with :hover and :focus-visible:

<span class="has-tip">
  <button type="button" class="has-tip__trigger" aria-describedby="tip-export">
    Export
  </button>
  <span role="tooltip" id="tip-export" class="has-tip__bubble"
    >Download as CSV or PDF</span
  >
</span>
.has-tip {
  anchor-scope: --tip;
}

.has-tip__trigger {
  anchor-name: --tip;
}

.has-tip__bubble {
  position: absolute;
  position-anchor: --tip;
  position-area: top;
  margin-bottom: 0.5rem;
  padding: 0.375rem 0.625rem;
  border-radius: 0.375rem;
  background: #111827;
  color: #ffffff;
  font-size: 0.8125rem;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

.has-tip:hover .has-tip__bubble,
.has-tip:focus-within .has-tip__bubble {
  opacity: 1;
}

The anchor-scope property is the key to reusing one class across many tooltips. Without it, every trigger would declare the same --tip name, and each bubble would attach to the last one in the document. anchor-scope limits the name's visibility to that subtree, so each bubble finds its own trigger. It's newer than the core features, so verify support for your audience. The alternative is a unique anchor name per instance, set inline as in the popover example.

This version doesn't escape overflow: hidden ancestors the way a popover does, so use it for tooltips in open layouts.

Flipping When Space Runs Out

A tooltip above a button near the top of the viewport will be cut off. position-try-fallbacks tells the browser what to try instead:

.tooltip {
  position-area: top;
  position-try-fallbacks: flip-block;
}

flip-block mirrors the position across the block axis, so top becomes bottom. flip-inline mirrors left and right, and you can combine them with flip-block flip-inline. The browser tries each fallback in order and uses the first one that fits in the containing block, which for a popover is the viewport.

You can also list plain position-area values:

.menu {
  position-area: bottom span-right;
  position-try-fallbacks:
    top span-right,
    bottom span-left,
    top span-left;
}

For complex fallbacks, define named options with @position-try:

@position-try --menu-left {
  position-area: left span-bottom;
  margin: 0 0.5rem 0 0;
}

.menu {
  position-area: bottom span-right;
  margin-top: 0.5rem;
  position-try-fallbacks: flip-block, --menu-left;
}

A @position-try block can override placement-related properties like insets, margins, sizes, position-area, and alignment.

Hiding When the Anchor Scrolls Away

If the anchor scrolls out of its scroll container, an anchored tooltip may be left floating. position-visibility: anchors-visible hides it automatically when the anchor is no longer visible:

.tooltip {
  position-visibility: anchors-visible;
}

A Complete Dropdown Menu

Putting it together, here's an accessible actions menu with a popover and anchor positioning:

<button
  type="button"
  class="menu-trigger"
  popovertarget="row-actions"
  aria-haspopup="menu"
>
  Actions
</button>

<div id="row-actions" popover class="menu" role="menu">
  <button type="button" role="menuitem">Rename</button>
  <button type="button" role="menuitem">Duplicate</button>
  <button type="button" role="menuitem" class="menu__danger">Delete</button>
</div>
.menu-trigger {
  anchor-name: --row-actions;
}

.menu {
  position-anchor: --row-actions;
  position-area: bottom span-right;
  position-try-fallbacks:
    flip-block,
    flip-inline,
    flip-block flip-inline;
  min-width: anchor-size(width);
  margin: 0.375rem 0 0;

  display: none;
  padding: 0.25rem;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  background: #ffffff;
  box-shadow: 0 12px 32px rgb(0 0 0 / 0.12);
}

.menu:popover-open {
  display: grid;
}

.menu [role="menuitem"] {
  padding: 0.5rem 0.75rem;
  border: 0;
  border-radius: 0.375rem;
  background: transparent;
  text-align: left;
}

.menu [role="menuitem"]:hover,
.menu [role="menuitem"]:focus-visible {
  background: #f3f4f6;
}

.menu__danger {
  color: #dc2626;
}

Clicking outside or pressing Escape closes the menu, thanks to popover light dismiss. If you use role="menu", remember that users will expect arrow key navigation between items, which still needs a little JavaScript. If you don't want to implement that, leave the roles off and use plain buttons, which are perfectly accessible on their own.

Browser Support and Fallbacks

Anchor positioning shipped first in Chromium-based browsers, and Safari has added support in its recent releases. Firefox has been implementing it, so check caniuse for the current state before relying on it for critical UI. Some parts, like anchor-scope, position-visibility, and implicit anchors, have narrower support than the core anchor-name, position-anchor, position-area, and anchor() features.

Feature-detect with @supports and provide a simple fallback:

/* Fallback: a centered popover, or a simple absolute position */
.tooltip {
  position: absolute;
  bottom: calc(100% + 0.5rem);
  left: 50%;
  translate: -50% 0;
}

@supports (anchor-name: --a) {
  .tooltip {
    bottom: auto;
    left: auto;
    translate: none;
    position-anchor: --share;
    position-area: top;
    position-try-fallbacks: flip-block;
  }
}

The fallback relies on the tooltip being inside a position: relative wrapper around the trigger, which works everywhere but doesn't flip. For popovers in unsupported browsers, the default centered-in-viewport placement is a reasonable fallback too. If precise placement is essential in every browser, the Floating UI library offers a well-maintained JavaScript solution and there's also an official anchor positioning polyfill from the Oddbird team.

Common Pitfalls

  • Forgetting position: absolute or fixed. Anchor positioning only applies to absolutely positioned elements (popovers are fixed by default).
  • Duplicate anchor names. If several elements share an anchor-name, the last one in the document wins. Use unique names or anchor-scope.
  • Popover margins. The default margin: auto on popovers interferes with placement; reset it.
  • Anchor order. The anchor must be laid out before the positioned element can reference it; the positioned element generally needs to come after the anchor in the tree or be in a different containing block (popovers in the top layer satisfy this automatically).
  • Clipping. A non-popover anchored element can still be clipped by an ancestor with overflow: hidden.

Conclusion

CSS anchor positioning replaces a whole category of JavaScript. Name an anchor with anchor-name, attach a positioned element with position-anchor, place it with position-area or anchor(), and let position-try-fallbacks flip it when space runs out. Combined with the Popover API, you get tooltips and menus that render above everything, close on Escape and outside clicks, and stay glued to their triggers as the page scrolls.

Support is broad enough to use it today as a progressive enhancement, with a simple absolute-position fallback for browsers that haven't caught up. Start with your simplest tooltip, and you'll quickly find yourself deleting positioning code everywhere else.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading