Tooltip
OverlayA tooltip with no positioning library — the popover attribute puts it in the top layer and CSS anchor positioning places it against the trigger.
Preview
Tab to the button to see it on focus too. Escape dismisses it without moving the pointer.
Controls
Notes
The reason to reach for the popover attribute is the top layer, not the show and hide. A tooltip that is an absolutely positioned span gets clipped by the first ancestor with overflow: hidden, which is usually a card or a table cell, and no amount of z-index fixes it.
Anchor positioning is Baseline 2026 — Chrome 125, Safari 18.2, Firefox 132 — and replaces what Floating UI was doing. The flip needs Safari 18.4, so 18.2 and 18.3 place the tip correctly but will not turn it away from a viewport edge. That is why position-try-fallbacks is declared rather than assumed.
Where anchor positioning is missing entirely the tip does not render at all, because a popover with nothing anchoring it lands in the middle of the screen. That degradation is only acceptable because of the next rule, and it is the reason the rule matters.
Nothing may live in a tooltip and nowhere else. It does not exist on touch, it does not survive a copy of the page, and a reader who never hovers never sees it. A tooltip annotates; it does not inform.
It is not a label either. The trigger gets aria-describedby, so the tooltip is read after the control's own name — a button whose only name is its tooltip is an unlabelled button, and wants aria-label instead.
Escape dismisses it without moving the pointer, which SC 1.4.13 requires of anything shown on hover, and it opens on focus as well as hover so the keyboard reaches it at all.
popover="manual", not "auto": auto light-dismisses on any outside click and closes other open popovers, and a tooltip should do neither.
@starting-style gives the entry a "from" state. Without it an element that did not exist a frame ago has nothing to transition out of, and the tip snaps in at full size.
Source
ShowHide
"use client";
import { cloneElement, useId, useRef } from "react";
import { cn } from "@/lib/cn";
/**
* A tooltip with no positioning library.
*
* Two platform features do the work that Floating UI used to. The popover
* attribute puts the tip in the TOP LAYER, so no `overflow: hidden` ancestor
* can clip it — that, not the show/hide, is the reason to reach for the API.
* CSS anchor positioning then places it against the trigger with anchor(),
* and position-try-fallbacks flips it when it would leave the viewport.
*
* Anchor positioning is Baseline 2026. The flip needs Safari 18.4+; on 18.2
* and 18.3 you get correct placement without it. Where anchor positioning is
* missing entirely the tip does not render at all, because a popover with
* nothing anchoring it lands in the middle of the screen — and a tooltip that
* never appears is only acceptable because of the rule below: nothing may live
* in a tooltip and nowhere else.
*/
const css = `
.tip-anchor { anchor-name: var(--tip-name); }
.tip {
position: fixed;
position-anchor: var(--tip-name);
/* UA styles give a popover a border, padding and auto margins. All three
have to go or the tip is centred in the viewport with a box around it. */
margin: 0;
border: 0;
padding: 0.4rem 0.6rem;
max-inline-size: 16rem;
inline-size: max-content;
border-radius: 0.5rem;
background: var(--ink);
color: var(--paper);
font-size: 12.5px;
line-height: 1.45;
text-align: center;
position-try-fallbacks: flip-block, flip-inline;
opacity: 0;
scale: 0.96;
transition:
opacity 0.14s ease,
scale 0.14s ease,
overlay 0.14s allow-discrete,
display 0.14s allow-discrete;
}
.tip:popover-open { opacity: 1; scale: 1; }
/* Without @starting-style the entry has no "from" and the tip snaps in. */
@starting-style {
.tip:popover-open { opacity: 0; scale: 0.96; }
}
.tip[data-place="top"] { bottom: anchor(top); left: anchor(center); translate: -50% -8px; }
.tip[data-place="bottom"] { top: anchor(bottom); left: anchor(center); translate: -50% 8px; }
.tip[data-place="left"] { right: anchor(left); top: anchor(center); translate: -8px -50%; }
.tip[data-place="right"] { left: anchor(right); top: anchor(center); translate: 8px -50%; }
.tip-arrow::after {
content: "";
position: absolute;
inline-size: 8px;
block-size: 8px;
background: inherit;
rotate: 45deg;
}
.tip-arrow[data-place="top"]::after { bottom: -3px; left: 50%; margin-left: -4px; }
.tip-arrow[data-place="bottom"]::after { top: -3px; left: 50%; margin-left: -4px; }
.tip-arrow[data-place="left"]::after { right: -3px; top: 50%; margin-top: -4px; }
.tip-arrow[data-place="right"]::after { left: -3px; top: 50%; margin-top: -4px; }
/* A popover with no anchor support centres itself on screen. Better nothing. */
@supports not (anchor-name: --probe) {
.tip { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tip { transition: none; }
}
`;
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
export function Tooltip({
label,
placement = "top",
arrow = true,
children,
}: {
label: string;
placement?: TooltipPlacement;
arrow?: boolean;
/** The trigger. It is cloned to receive aria-describedby. */
children: React.ReactElement;
}) {
const raw = useId();
// useId's value carries characters a dashed-ident cannot hold, so strip them.
const name = `--tip-${raw.replace(/[^a-zA-Z0-9]/g, "")}`;
const tipId = `${raw}-tip`;
const tip = useRef<HTMLDivElement>(null);
// showPopover() throws if it is already open and hidePopover() throws if it
// is already closed, and pointer and focus events overlap constantly.
function show() {
try {
tip.current?.showPopover();
} catch {}
}
function hide() {
try {
tip.current?.hidePopover();
} catch {}
}
return (
<span
className="tip-anchor relative inline-flex"
style={{ "--tip-name": name } as React.CSSProperties}
onPointerEnter={show}
onPointerLeave={hide}
// focus/blur, not focusin/focusout by hand: React's versions bubble, so
// the trigger inside gets covered without a second listener.
onFocus={show}
onBlur={hide}
// SC 1.4.13 — content shown on hover or focus must be dismissible
// without moving the pointer.
onKeyDown={(event) => {
if (event.key === "Escape") hide();
}}
>
{cloneElement(children as React.ReactElement<{ "aria-describedby"?: string }>, {
"aria-describedby": tipId,
})}
<div
ref={tip}
id={tipId}
// "manual" rather than "auto": auto light-dismisses on any outside
// click and closes other popovers, neither of which a tooltip wants.
popover="manual"
role="tooltip"
data-place={placement}
className={cn("tip", arrow && "tip-arrow")}
>
{label}
</div>
</span>
);
}
export default function Demo({
label = "Copied to your clipboard",
placement = "top",
arrow = true,
}: {
label?: string;
placement?: TooltipPlacement;
arrow?: boolean;
}) {
return (
<div className="flex w-full max-w-sm flex-col items-center gap-6">
<style href="tooltip-anchor" precedence="default">
{css}
</style>
<Tooltip label={label} placement={placement} arrow={arrow}>
<button
type="button"
className="rounded-full border border-control-border px-5 py-2.5 text-sm text-ink transition-colors hover:border-ink hover:bg-paper-sunken"
>
Hover or focus me
</button>
</Tooltip>
<p className="text-center text-[12.5px] leading-relaxed text-ink-faint">
Tab to the button to see it on focus too. Escape dismisses it without
moving the pointer.
</p>
</div>
);
}
Next
DialogNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.