The WAI-ARIA Tooltip pattern on the platform: the popup lives in the top layer (Popover API), is placed with CSS Anchor Positioning and animates with @starting-style. The library's JavaScript is a thin shim for hover and focus that steps aside where the browser can do the job.
// No "use client": this file is a Server Component. Tooltip has no action props, so every prop
// is serialisable and a Server Component renders it directly.
import { Tooltip } from "@funstack/ui/tooltip";
function BoldIcon() {
return (
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path
d="M4 2h5a3 3 0 0 1 2.1 5.1A3.3 3.3 0 0 1 9.5 14H4V2Zm2 2v3h3a1.5 1.5 0 0 0 0-3H6Zm0 5v3h3.5a1.5 1.5 0 0 0 0-3H6Z"
fill="currentColor"
/>
</svg>
);
}
function ItalicIcon() {
return (
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path d="M6 2h7v2h-2.6l-2.3 8H10v2H3v-2h2.6l2.3-8H6V2Z" fill="currentColor" />
</svg>
);
}
export function TooltipToolbar() {
return (
<div className="demo-row">
<div className="demo-toolbar" role="toolbar" aria-label="Formatting">
{/* Icon-only buttons: the tooltip text is the button's name (aria-labelledby). */}
<Tooltip.Root provides="name">
<Tooltip.Trigger className="demo-icon-button">
<BoldIcon />
</Tooltip.Trigger>
<Tooltip.Popup className="demo-tip">Bold</Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root provides="name">
<Tooltip.Trigger className="demo-icon-button">
<ItalicIcon />
</Tooltip.Trigger>
<Tooltip.Popup className="demo-tip">Italic</Tooltip.Popup>
</Tooltip.Root>
</div>
{/* A button with its own text: the tooltip is a description (aria-describedby). */}
<Tooltip.Root>
<Tooltip.Trigger className="demo-button">Save</Tooltip.Trigger>
<Tooltip.Popup className="demo-tip" side="bottom" align="start">
Saves the document (Ctrl+S)
</Tooltip.Popup>
</Tooltip.Root>
{/* Chromium: the browser owns hover, focus and dismissal. Elsewhere: the JS path. */}
<Tooltip.Root delay={400}>
<Tooltip.Trigger className="demo-button" experimental_interestFor>
Native where supported
</Tooltip.Trigger>
<Tooltip.Popup className="demo-tip" side="right" experimental_hint>
interestfor + popover="hint"
</Tooltip.Popup>
</Tooltip.Root>
</div>
);
}/* Tooltip: open state belongs to the browser, so style it with :popover-open and animate it
with @starting-style. The library ships no CSS. */
.demo-tip {
margin: 6px; /* gap from the trigger; overrides the UA's margin: auto */
padding: 4px 8px;
border: 0;
border-radius: 4px;
background: var(--color-text);
color: var(--color-bg);
font-size: 0.875rem;
max-inline-size: 24rem;
opacity: 0;
translate: 0 4px;
transition:
opacity 120ms,
translate 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-tip:popover-open {
opacity: 1;
translate: 0 0;
@starting-style {
opacity: 0;
translate: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-tip {
transition: none;
}
}
.demo-toolbar {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.demo-icon-button {
display: inline-grid;
place-items: center;
inline-size: 2rem;
block-size: 2rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
}
/* The trigger whose tooltip is open: the popup is the next sibling. */
.demo-icon-button:has(+ .demo-tip:popover-open),
.demo-button:has(+ .demo-tip:popover-open) {
border-color: var(--color-primary);
}Hover a button, or move focus to it with the keyboard. Escape, or any click, closes the tooltip. This example is a Server Component.
import { Tooltip } from "@funstack/ui/tooltip";
<Tooltip.Root> {/* no DOM: id, options, interaction */}
<Tooltip.Trigger>Save</Tooltip.Trigger> {/* button */}
<Tooltip.Popup>Saves the document (Ctrl+S)</Tooltip.Popup> {/* div[popover][role=tooltip] */}
</Tooltip.Root><button type="button" aria-describedby="_r_1_">Save</button>
<div id="_r_1_" popover="manual" role="tooltip" data-side="top" data-align="center"
style="position-area: top; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline">
Saves the document (Ctrl+S)
</div>Root renders no DOM, so the trigger stays a direct child of your toolbar or flex row. There is no Portal and no Positioner: the top layer and anchor positioning do those jobs.Arrow part in this version. Draw one on the popup with CSS if you need it (it cannot follow a fallback flip).Tooltip.Root| Prop | Type | Default | Description |
|---|---|---|---|
delay | number | 250 | Milliseconds from hover or keyboard focus to open (Chromium's interest-delay-start). Hover and focus share it. |
closeDelay | number | 150 | Milliseconds from leaving (trigger and popup) or blur to close (Chromium's interest-delay-end). |
disabled | boolean | false | The tooltip never opens. The ARIA relation stays in place. |
provides | "description" | "name" | "description" | Which relation the tooltip provides for the trigger. "description": aria-describedby, for triggers with their own name. "name": aria-labelledby, for icon-only triggers; do not also set aria-label. |
children | ReactNode | ((state: TooltipRootState) => ReactNode) | – | The Trigger and the Popup. |
TooltipRootState: { disabled }. No ref, className or style (no DOM), and no open, defaultOpen or action (see below).
Tooltip.Trigger| Prop | Type | Default | Description |
|---|---|---|---|
experimental_interestFor | boolean | false | Renders interestfor and interest-delay-start/end so the browser owns hover, focus, Escape and long-press. Where the element implements interestForElement (Chromium), the library attaches no listeners; elsewhere the attribute is ignored and the JS path runs. Pair with Popup experimental_hint. |
className | string | ((state: TooltipTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TooltipTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TooltipTriggerState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: TooltipTriggerState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
TooltipTriggerState: { disabled }. Every <button> prop passes through, including onClick and type (default "button"): the library attaches its listeners with addEventListener. Your own aria-describedby / aria-labelledby ids are merged with the tooltip's.
Tooltip.Popup| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | null | "top" | Placement through inline position-area, with position-try-fallbacks flipping it when it does not fit. null emits no inline positioning and no data-side / data-align, so your stylesheet owns placement. |
align | "start" | "center" | "end" | "center" | Alignment along the side. Ignored when side is null. |
experimental_hint | boolean | false | Renders popover="hint" instead of "manual": native light dismiss, and the tooltip can open over an open menu without closing it. Engines that do not know hint treat it as manual, and the JS Escape and click handling stays. |
className | string | ((state: TooltipPopupState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TooltipPopupState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TooltipPopupState) => ReactNode) | – | Rendered at mount (hidden), not at open time. Keep it static and non-interactive. |
render | ReactElement | ((props, state: TooltipPopupState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
TooltipPopupState: { disabled, side, align }. id, popover and role are owned by the library. Your style wins over the inline placement, so style={{ positionArea: "bottom span-right" }} works for a one-off.
There is no open prop, no openChangeAction and no data-open. The popup is a popover in the top layer, opened and closed by the browser (or by the library's listeners calling showPopover / hidePopover), never through React state. Style it with :popover-open, and the trigger of an open tooltip with :has(+ :popover-open). Tooltip is therefore the one component with no transitions, no data-pending and no transition prop; animate it in CSS:
.tip {
opacity: 0;
translate: 0 4px;
transition: opacity 120ms, translate 120ms, display 120ms allow-discrete, overlay 120ms allow-discrete;
}
.tip:popover-open {
opacity: 1;
translate: 0 0;
@starting-style { opacity: 0; translate: 0 4px; }
}
@media (prefers-reduced-motion: reduce) {
.tip { transition: none; }
}To observe opens (for analytics), listen to the popup's native toggle event through a ref.
| Hook | On | Meaning |
|---|---|---|
:popover-open | Popup | The tooltip is showing. |
data-side | Popup | The requested side (absent with side={null}). |
data-align | Popup | The requested alignment (absent with side={null}). |
data-disabled | Trigger, Popup | The Root's disabled prop. |
data-side is the side you asked for, not the side the browser picked after a fallback flip; CSS exposes no signal for that yet.
| Interaction | Behaviour |
|---|---|
| Hover the trigger | Opens after delay. Stays open while the pointer is over the trigger or the popup. |
| Pointer leaves both | Closes after closeDelay. |
| Keyboard focus on the trigger | Opens after delay (focus from a click does not open). |
| Blur | Closes after closeDelay. |
| Escape | Closes; focus stays on the trigger. |
| Any pointerdown | Closes (outside, on the trigger, on the popup). It reopens only after the pointer leaves and re-enters, or focus leaves and returns. |
| Touch | Never opens on the JS path (no hover). Chromium's native path offers long-press. |
role="tooltip"; the trigger gets aria-describedby or aria-labelledby per provides, and no aria-expanded. The relation works while the popup is hidden, so screen readers announce the text on focus in every engine.disabled button never shows a tooltip; use aria-disabled="true" on a control that needs one.In development, the library warns when provides="name" is combined with an aria-label, when the trigger is not focusable, and when the popup contains focusable content.
| Flags | Chromium | Firefox | Safari |
|---|---|---|---|
| none | manual popover + JS | same | same |
experimental_hint | hint popover: native light dismiss, JS still attached | same as Chromium | hint unknown: behaves as manual + JS |
experimental_interestFor | native trigger, no JS listeners | attribute ignored, JS path | attribute ignored, JS path |
| both | fully native, works before hydration | as experimental_hint | as no flags |
<Tooltip.Root provides="name">
<Tooltip.Trigger experimental_interestFor className="icon-button">
<SaveIcon />
</Tooltip.Trigger>
<Tooltip.Popup experimental_hint className="tip">Save</Tooltip.Popup>
</Tooltip.Root>The baseline popover type is manual, not auto, so that opening a tooltip never closes an open menu or listbox.
<Tooltip.Root>
<Tooltip.Trigger render={<a href="/pricing" />}>Pricing</Tooltip.Trigger>
<Tooltip.Popup className="tip">Opens the pricing page</Tooltip.Popup>
</Tooltip.Root>The native interestfor path only applies to buttons and links; any other element (a focusable <span tabIndex={0}>) always uses the JS path.
<Tooltip.Popup side={null} className="tip tip-inline-end">...</Tooltip.Popup>.tip-inline-end {
position-area: inline-end;
position-try-fallbacks: flip-inline;
margin-inline: 6px;
}