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.
Step-by-step resolution
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.
Verification checklist
Constraints and trade-offs
popover=hintoffers lighter-weight dismissal behaviour but has narrower support thanmanual.- Anchor positioning support is still spreading; provide a fallback position that is usable.
- Inline
anchor-namestyles 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.
Related
- CSS Anchor Positioning & Motion — the parent topic
- Animating Popover and Dialog Entry and Exit — top-layer transitions in depth
- Anchored Menus with Popover Entry Effects — the interactive counterpart to tooltips