Anchor-Positioned Tooltips: Tethering, Flipping, and Getting the Semantics Right

The narrow problem here is a familiar one with a new solution. You have a small explanatory bubble that should appear beside a trigger on hover and on keyboard focus, sit as close to that trigger as the layout allows, and — critically — not render half off the screen when the trigger happens to be the last item in a toolbar pressed against the right edge of a narrow phone. Historically the last requirement is what forced a JavaScript dependency into an otherwise trivial component. This page, part of the CSS anchor positioning and overlays section, builds that tooltip with position-anchor for the tether and position-try-fallbacks for the collision handling, then spends real time on the part that CSS cannot help with at all: making the browser's accessibility tree agree with what the eye sees.

Why CSS placement, and what it does not buy you

The argument for moving placement into CSS is not primarily about bundle size, though a few kilobytes are pleasant to lose. It is about correctness under conditions your script never observes. A JavaScript positioner recalculates on the events it listens for — scroll, resize, maybe a ResizeObserver on the trigger. It does not recalculate when a web font swaps in and reflows the toolbar, when a sibling's image finally decodes, or when the user zooms text to 200% and the trigger moves. Anchor positioning is evaluated as part of layout itself, so it is correct on every frame by construction, including the frames your listeners never hear about.

The tradeoff is a recent one rather than a narrow one: Chrome and Edge shipped the feature in version 125, Safari in 26, and Firefox in 147. Every current engine has it, but the ship dates are new enough that older versions in circulation do not, so this is still best treated as a decoration on top of a placement that already works rather than the only placement you ship — the reasoning and the code for that split live in anchor positioning fallbacks.

The other thing CSS placement does not buy you is meaning. This deserves stating bluntly because the visual result is so convincing that it is easy to stop there. Putting a box eight pixels above a button creates exactly zero relationship between them in the accessibility tree. A screen reader user tabbing to that button hears the button's label and nothing else; the bubble is a disconnected region living somewhere in the DOM. The tooltip needs two independent wires connected — one for pixels, one for semantics — and only the first is CSS's job.

Two independent wires: placement and association The left column shows the CSS properties that place the tooltip; the right column shows the HTML attributes that associate it with the trigger for assistive technology. Both wires must be connected where the box paints anchor-name: --tip position-anchor: --tip what the screen reader hears aria-describedby="t1" id="t1" role="tooltip" CSS supplies the left column only; the right column is markup you must write

Complete working implementation

Two triggers sit at opposite edges of the frame below. Focus one with Tab or hover it, then drag the frame narrower: the bubbles flip rather than spill.

Live demoTooltip that flips when it would overflow
Focus or hover a trigger, then drag the frame narrower — the tooltip flips sides rather than spilling out. Drag the bottom-right corner to resize the frame in either direction.
<span class="tt">
  <button type="button" class="tt__trigger" aria-describedby="tt-a">
    Retention policy
  </button>
  <span role="tooltip" id="tt-a" class="tt__bubble">
    Records are purged 30 days after the account closes.
  </span>
</span>
/* The wrapper exists only to keep trigger and bubble adjacent in the DOM.
   It is NOT a containing block — anchor placement does not need one. */
.tt { display: inline-block; }

.tt__trigger {
  /* Publishes the reference frame. Purely declarative: nothing moves yet. */
  anchor-name: --tt;
  font: inherit;
  padding: 0.4rem 0.7rem;
  border: 1px solid #d3d8e0;
  border-radius: 0.4rem;
  background: #fff;
}

.tt__bubble {
  /* Fixed, not absolute: the anchor supplies the reference frame, so the
     bubble follows the trigger on scroll and no ancestor can clip it. */
  position: fixed;
  position-anchor: --tt;

  /* Preferred placement: centred above the trigger. */
  position-area: block-start center;

  /* If that overflows, try below, then mirrored, then diagonally. */
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;

  /* One margin serves every candidate, because logical margins flip with
     the placement. A hard-coded margin-bottom would not. */
  margin: 0.4rem;

  max-width: 14rem;
  padding: 0.45rem 0.65rem;
  border-radius: 0.45rem;
  background: #1e2430;
  color: #fff;
  font-size: 0.8rem;

  /* Opacity-only reveal keeps the animation off the layout path, which
     matters because the placement is recomputed during layout. */
  opacity: 0;
  transition: opacity 0.15s ease;
}

.tt__trigger:hover + .tt__bubble,
.tt__trigger:focus-visible + .tt__bubble {
  opacity: 1;
}

The bubble is never removed from the box tree — it fades between opacity: 0 and 1 and stays in the layout. That is a deliberate choice for role="tooltip": the element referenced by aria-describedby must remain in the accessibility tree for the reference to resolve, and display: none would take it out of that tree entirely, leaving the trigger with a dangling reference and no description.

The technique: fallbacks are re-layouts, not transforms

The property doing the interesting work is position-try-fallbacks. It is worth being precise about what happens, because the mental model people import from JavaScript positioners is subtly wrong.

A library typically computes a position, measures the resulting rectangle against the viewport, and then nudges — shifting the element by the overflow amount, or swapping a sign in the arithmetic. Anchor positioning does not nudge. The browser lays the element out with the primary placement, tests whether the resulting box overflows its inset-modified containing block, and if it does, throws that layout away and lays the element out again from scratch using the next candidate. Each candidate is a complete, independent placement.

That distinction has three practical consequences. First, a candidate can legitimately change more than direction: a @position-try rule may alter margins, sizing and alignment too, so "flip to below" and "flip to below, and also become full width" are both single candidates. Second, only one candidate ever applies at a time — you never end up in a blended state between two. Third, the last candidate in the list is the one used when nothing fits, so put your most tolerant placement last rather than assuming the browser will pick a least-bad option on its own.

The margin: 0.4rem shorthand pairs with this. Because each candidate is a fresh layout with a fresh notion of which side faces the anchor, a symmetric margin gives you the same visual gap in all four directions without writing four rules. A margin-block-end: 0.4rem would produce a gap above the trigger and none below it after a flip-block.

Getting the association right

Now the half that CSS cannot do. The rules are short and each one is load-bearing:

The trigger must be genuinely focusable. A <button> or a link. Not a <span> with tabindex="0" and a click handler, which announces as generic text, and not an icon with no accessible name. If the trigger is icon-only, give it one: <button aria-label="Retention policy">.

The tooltip must be referenced, not merely adjacent. aria-describedby on the trigger pointing at the tooltip's id is what makes a screen reader read the bubble's text after the button's name. role="tooltip" on the bubble tells assistive technology what kind of thing it is. Neither is implied by proximity.

The tooltip must contain no interactive content. This is the constraint that most often forces a different component. role="tooltip" describes a passive hint; there is no reliable, standards-blessed way for a keyboard user to move focus into a tooltip. If your bubble needs a link or a button in it, it is not a tooltip — reach for a popover panel opened by click, which is what the popover attribute and CSS styling page builds.

Hover and focus must both work, and the content must survive contact. WCAG 1.4.13 requires content revealed on hover or focus to be dismissible, hoverable and persistent. The :focus-visible selector above covers the keyboard reveal; the margin gap is small enough that pointer travel between trigger and bubble rarely drops the hover state, but if you widen the gap you need a transparent bridge to keep the hit area continuous. The full treatment of those three criteria, including the one that genuinely needs a keypress handler, is in accessible CSS-only tooltips.

Do not reuse one anchor name across a list. Twenty toolbar buttons all declaring anchor-name: --tt will all resolve to the last one, and every tooltip in the toolbar will appear over the final button. Emit a per-instance name from your templating layer, or set it inline from a custom property:

.tt__trigger { anchor-name: var(--tt-name); }
<button class="tt__trigger" style="--tt-name: --tt-3" aria-describedby="tt-3">…</button>

Variation: reduced motion and a stable reading position

Two refinements matter for users who are sensitive to movement. The first is obvious: honour prefers-reduced-motion by dropping the fade to an instant swap. The second is less obvious and more useful — a tooltip that flips its side as the user scrolls is far more disruptive than one that simply disappears. Pinning the tooltip's visibility to the anchor's own visibility prevents both the pointing-at-nothing state and the mid-scroll flip.

@media (prefers-reduced-motion: reduce) {
  .tt__bubble { transition: none; }
}

.tt__bubble {
  /* Stop painting the bubble once the trigger scrolls out of the scrollport
     rather than pinning it to the edge with nothing to point at. */
  position-visibility: anchors-visible;
}

For right-to-left interfaces no extra work is required, and that is worth noticing rather than skipping past. Because the implementation uses position-area: block-start center and the logical flip keywords rather than top and left, the placement mirrors automatically under direction: rtl. Had the same tooltip been written with left: anchor(left), it would need a separate rule per writing mode.

Browser support

Anchor positioning ships in Chrome 125+, Edge 125+, Safari 26+ and Firefox 147+. The two properties this page leans on hardest arrived slightly later in Chromium: position-try-fallbacks in 128 and position-area in 129. Anything built on an older engine than that — which still includes a meaningful slice of embedded webviews — needs the fallback placement. Everything else on this page is universally supported: :focus-visible (Chrome 86+, Edge 86+, Firefox 85+, Safari 15.4+), aria-describedby, and opacity transitions carry no caveats. Guard only the placement block with @supports (anchor-name: --x) and let the rest apply everywhere.

FAQ

Does anchor positioning make a tooltip accessible? No. It only changes where the box paints. The tooltip still needs a real focusable trigger, an aria-describedby reference from that trigger to the tooltip's id, and DOM order that places the tooltip next to its trigger.

Should the tooltip use role="tooltip" or the popover attribute? Use role="tooltip" for a short descriptive hint revealed by hover and focus. The popover attribute is a better fit when the panel is opened by a deliberate click and contains interactive controls, because it brings light dismiss and Escape handling with it.

Why does my tooltip flip when there is clearly room? The overflow test runs against the inset-modified containing block, not the visible viewport. Large margins, a scrollbar gutter, or an inset declaration left over from a fallback stylesheet shrink that rectangle and make a fitting placement look like an overflow.

How do I keep the tooltip from following a trigger that has scrolled away? Set position-visibility: anchors-visible on the tooltip. The browser then stops painting it once the anchor leaves the scrollport, instead of pinning it to the edge pointing at nothing.

Related articles

More pages in the same section.