Animating Tooltips with Anchor Positioning

Part of CSS Anchor Positioning & Motion in Modern View Transitions & Scroll APIs.

The problem

An icon toolbar has tooltips. The current implementation uses a positioning library, a mouseenter listener that measures the icon and writes coordinates, and a CSS class for the fade. Tooltips flicker when the pointer moves between adjacent icons, appear behind a sticky header because of stacking contexts, detach from their icons when a container scrolls, and do not appear for keyboard users at all.

Every one of those problems has a platform answer: the popover API for stacking and dismissal, anchor positioning for placement and attachment, @starting-style for motion, and a few lines of script for timing.

Root cause analysis: four jobs, four features

Stacking. A tooltip inside a component inherits that component’s stacking context, so a sticky header with a higher z-index covers it. A popover shown with showPopover() renders in the top layer, above everything, regardless of where it is in the DOM.

Placement and attachment. anchor-name on the trigger and position-anchor plus position-area on the tooltip place it relative to the trigger in CSS. When a scroll container moves the trigger, the browser keeps the tooltip attached, as described in the parent topic.

Motion. Entering and leaving the top layer involves display and overlay, both discrete. Transitioning them with allow-discrete keeps the tooltip rendered and in the top layer during its fade-out; @starting-style gives the fade-in a starting point. The rise toward the anchor is a small translate, applied after anchoring so it is relative to the resolved position.

Timing. Tooltips should not appear the instant a pointer crosses an icon; a short delay avoids a storm of tooltips while moving across a toolbar. Once one tooltip is showing, moving to an adjacent icon should switch immediately — the “warm-up” behaviour of native tooltips. Hiding needs a short delay too, so moving the pointer from the trigger toward the tooltip does not close it. This timing is state, and lives in script.

Accessibility. The tooltip needs role="tooltip" and the trigger needs aria-describedby pointing at it; focus must show it and Escape must hide it.

Tooltip timing statesThe warm state lets adjacent tooltips switch without the initial delay.Tooltip timing statesIdlePending showVisibleWarmhover or focusafter 400 msleaveafter 300 ms
The warm state lets adjacent tooltips switch without the initial delay.

Step-by-step resolution

Building the tooltipCSS for placement and motion, script for timing and ARIA.Building the tooltip1Add anchor-name to each trigger and aria-describedby pointing to its tooltip.Visual and semantic relationships2Give the tooltip popover=manual, role=tooltip and position-anchor.Top layer and anchoring3Place it with position-area: block-start and flip-block fallback.Above, or below near the top edge4Transition opacity, translate, display and overlay with a starting style.Fade and rise in both directions5Show after 400 ms on hover or immediately when warm; hide after 100 ms.No flicker across toolbars6Show on focus, hide on blur and Escape.
CSS for placement and motion, script for timing and ARIA.

Production code pattern

<button class="tool" style="anchor-name: --tool-bold" aria-describedby="tip-bold">
  <svg viewBox="0 0 24 24" aria-hidden="true" class="icon">…</svg><span class="visually-hidden">Bold</span>
</button>
<div id="tip-bold" class="tip" role="tooltip" popover="manual" style="position-anchor: --tool-bold">
  Bold (Ctrl+B)
</div>
.tip {
  position: absolute;
  inset: auto;
  margin: 0;
  position-area: block-start;
  margin-block-end: 8px;
  position-try-fallbacks: flip-block;
  opacity: 0;
  translate: 0 4px;                                  /* rises toward its resting place */
  transition:
    opacity var(--duration-100) linear,
    translate var(--duration-100) var(--ease-accelerate),
    display var(--duration-100) allow-discrete,
    overlay var(--duration-100) allow-discrete;
}
.tip:popover-open {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity var(--duration-200) linear,
    translate var(--duration-200) var(--ease-decelerate),
    display var(--duration-200) allow-discrete,
    overlay var(--duration-200) allow-discrete;
  @starting-style { opacity: 0; translate: 0 4px; }
}

@media (prefers-reduced-motion: reduce) {
  .tip, .tip:popover-open { translate: none; }
}
let warm = false, coolTimer, showTimer;

function wire(trigger) {
  const tip = document.getElementById(trigger.getAttribute('aria-describedby'));
  const show = (delay) => {
    clearTimeout(showTimer); clearTimeout(coolTimer);
    showTimer = setTimeout(() => { tip.showPopover(); warm = true; }, warm ? 0 : delay);
  };
  const hide = () => {
    clearTimeout(showTimer);
    setTimeout(() => tip.matches(':popover-open') && tip.hidePopover(), 100);
    coolTimer = setTimeout(() => { warm = false; }, 300);
  };
  trigger.addEventListener('pointerenter', () => show(400));
  trigger.addEventListener('pointerleave', hide);
  trigger.addEventListener('focus', () => show(0));
  trigger.addEventListener('blur', hide);
  trigger.addEventListener('keydown', (e) => { if (e.key === 'Escape') tip.hidePopover(); });
}
document.querySelectorAll('.tool[aria-describedby]').forEach(wire);

Rendering Impact: layout once when the popover opens to resolve its anchored position, then composite for the fade and 4px rise. Top-layer rendering avoids stacking-context workarounds that often force extra layers.

The exit uses a faster duration and accelerating easing than the entry, following choosing easing for enter and exit motion. With flip-block, a tooltip near the top of the viewport appears below its trigger; its 4px entrance still reads correctly because the offset is small, a trade-off explored in position-try fallbacks and motion.

Library-positioned tooltip against popover plus anchorThe platform version needs script only for timing.Library-positioned tooltip against popover plus anchorPositioning libraryMeasures and writes coordinates on eventsStacking context can hide itDetaches during container scrollMore script, more edge casespopover + anchor positioningTop layer above sticky headersBrowser keeps it attachedCSS handles placement and motionScript only for delays
The platform version needs script only for timing.

Verification checklist

Constraints and trade-offs

  • popover=hint offers lighter-weight dismissal behaviour but has narrower support than manual.
  • Anchor positioning support is still spreading; provide a fallback position that is usable.
  • Inline anchor-name styles per trigger are verbose; generate them in templates.
  • Tooltips should not contain interactive content; use a popover or dialog pattern for that.
  • Touch devices have no hover; ensure the information is available another way.

Frequently asked questions

Why use the popover API for tooltips?

It renders the tooltip in the top layer, above stacking contexts such as sticky headers, and provides consistent show and hide methods.

How do I stop tooltips flickering across a toolbar?

Delay the first tooltip by a few hundred milliseconds, then keep a short warm period during which adjacent tooltips show immediately.

Does anchor positioning keep tooltips attached while scrolling?

Yes. The browser updates the anchored position when the anchor moves, without scroll listeners.

What should the tooltip’s entry animation be?

A short fade with a few pixels of travel toward its resting position, under about 200ms, with a faster exit.