How to build a Tooltip in 2027

Beautiful and accessible tooltips without JavaScript using modern CSS

If you’ve worked on a design system, chances are you’ve built a tooltip. They look simple but have a surprising number of subtle details to get right. This used to take a lot of JavaScript, but modern CSS can get us very far. Here’s what we’ll build:

The finished result

NOTE

At the time of writing this, the interactive demos work fully in Chrome only, as some of these features don’t have broad browser support yet.

The Trigger

The most obvious trigger is :hover (or mouseenter / mouseleave), but that falls short in two ways:

  • Other input methods need to be treated differently: on a touchscreen, users tap to show the tooltip and keyboard users need to see it when the trigger element is focused.
  • The tooltip is not associated with the trigger element so the connection is purely visual and not accessible to screen readers.

Headless component libraries like Base UI successfully handle all of this in JavaScript but the platform now offers a declarative alternative: interest invokers let you wire up an interactive element to a popover using the interestfor attribute, leaving it to the browser to decide when to show the tooltip.

<button interestfor="tooltip">
hover me
</button>
<div popover="hint" id="tooltip">
Tooltip content
</div>

This also solves the z-index issue since popovers are placed on the top layer, which sits above the rest of the page. Browser support is pretty good: popovers are supported on baseline and a polyfill exists for interest invokers:

Popover Newly available across major browsers

Available in all major browsers since January 2025. Older browsers may not support it.

Chrome 116
Edge 116
Firefox 125
Safari 17
Interest invokers Limited availability across major browsers

Not fully supported across all major browsers yet.

Chrome 142
Edge 142
Firefox
Safari

Position

Our popover is floating in the middle of the screen. Tethering it to the trigger is another problem usually solved with JavaScript with libraries like Popper, but CSS can now do this directly too. With anchor positioning, we designate an element as an anchor and position other elements relative to it.

  • Usually, you first create the anchor association between two elements using anchor-name and position-anchor but interestfor already creates an implicit anchor association for us.
  • For positioning the tooltip, we use insets like top or inset-block-start with the anchor() function and alignment properties like justify-self.
  • When there is not enough space to show the tooltip, position-try-fallbacks allows us to flip the tooltip to the other side of the trigger:
#tooltip {
inset: auto;
bottom: calc(anchor(top) + 4px);
justify-self: anchor-center;
position-try-fallbacks: flip-block;
}
Anchor positioning Limited availability across major browsers

Not fully supported across all major browsers yet.

Chrome
Edge
Firefox
Safari

Shape

Our tooltip has two distinct visual parts:

  • A rounded rectangle for the tooltip content.
  • A triangular arrowhead which is smoothed out at the tip and the transition to the content rectangle.

You could draw the arrow separately with SVG but instead, we’ll use the border-shape property, which lets us describe custom outlines with an SVG path-like syntax via the shape() function. Tweak the variables in the playground below to see how they affect the border shape.

Code Playground
Playground loading…

Because the browser treats it as a regular border, backgrounds and box shadows still work beautifully.

border-shape Limited availability across major browsers

Not fully supported across all major browsers yet.

Chrome 147
Edge 147
Firefox
Safari

Flipping the arrow

Thanks to position-try-fallbacks, the browser already flips the tooltip below if it doesn’t fit above the trigger but the arrowhead is now pointing downwards, away from the trigger instead of at it:

To know that the flip has happened, we can use an anchored container query. When the fallback rule matches, we mirror the border-shape and flip the vertical padding. The only catch is that a container query can’t target the container itself so we need to add a wrapper:

<div popover="hint" id="tooltip">
<div class="tooltip-inner">Tooltip content</div>
</div>
#tooltip{
container-type: anchored;
/* other styles */
}
@container anchored(fallback: flip-block) {
.tooltip-inner {
/* same shape with mirrored arrow */
border-shape: shape(/* ... */);
/* flip paddings too */
padding-block-start: calc(var(--p) + var(--as));
padding-block-end: var(--p);
}
}
Anchor position container queries Limited availability across major browsers

Not fully supported across all major browsers yet.

Chrome 143
Edge 143
Firefox
Safari

Motion

Time to make it feel more dynamic. A subtle fade and scale reinforces the link to the trigger. To make the scale feel like it grows out of the trigger, we set transform-origin to roughly the trigger’s center, just past the tooltip’s bottom edge.

#tooltip {
transition:
display 0.15s allow-discrete,
overlay 0.15s allow-discrete;
}
.tooltip-inner {
opacity: 0;
scale: 0.9;
transform-origin: var(--ap) calc(100% + 16px);
transition: all 0.15s ease;
}
@starting-style {
#tooltip:popover-open .tooltip-inner {
opacity: 0;
scale: 0.9;
}
}
#tooltip:popover-open .tooltip-inner {
opacity: 1;
scale: 1;
}
  • The styles under :popover-open are the “to” state. @starting-style provides the matching “from” state, which the browser needs since the tooltip has no prior computed value to transition from when it comes out of display: none.
  • allow-discrete opts display and overlay into the transition so the tooltip stays in the top layer until the fade-out finishes.
@starting-style Newly available across major browsers

Available in all major browsers since August 2024. Older browsers may not support it.

Chrome 117
Edge 117
Firefox 129
Safari 17.5

When the tooltip is flipped, the trigger sits above it instead of below, so the origin needs to move to the other side. We can reuse the anchored container query from flipping the arrow to update only the transform-origin:

@container anchored(fallback: flip-block) {
.tooltip-inner {
transform-origin: var(--ap) -16px;
}
}

Shared hover delay

When you move across a row of triggers, all tooltips after the first one should open instantly instead of waiting out the delay again. Interest invokers give us the :interest-source pseudo-class, which matches an active invoker. Using :has() on a group of triggers like a toolbar, we can then disable the delay for all its items with interest-delay:

[interestfor] {
interest-delay: 1s;
}
.toolbar:has(:interest-source) [interestfor] {
interest-delay-start: 0s;
}

Try it out

Bringing it all together, we get an animated tooltip tethered to its trigger across input methods, built entirely with web platform features. Here’s a playground with the finished result:

Code Playground
Playground loading…

Regarding browser support, only Chrome ships border-shape and anchored container queries for now but if you skip the arrow and polyfill interest invokers, and the rest works in every modern browser today. In fact, that’s how the tooltips on this blog work! You can see them in action if you hover the publish date of this post.