Overlays
Tooltip
A short text description of a control, shown on hover and keyboard focus.
- Built on
Popover APICSS anchor positioningaria-describedby@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
- Dependencies
- React
Ships a41c9e2 to eu-west-1
Try its options
Loading its options.
Code
import { Button } from "@/components/equip/button";
import { Tooltip } from "@/components/equip/tooltip";
export default function Demo() {
return (
<Tooltip content="Ships a41c9e2 to eu-west-1">
<Button>Deploy</Button>
</Tooltip>
);
}Your versionchange its options in Look, This part
Examples
Placement
Either side flips to the other when the viewport edge is too close.
Last run 12 minutes agoKeeps the last 30 days
Code
import { Button } from "@/components/equip/button";
import { Tooltip } from "@/components/equip/tooltip";
export default function Example() {
return (
<div style={{ display: "flex", gap: 8 }}>
<Tooltip content="Last run 12 minutes ago">
<Button variant="outline">Run checks</Button>
</Tooltip>
<Tooltip content="Keeps the last 30 days" placement="bottom">
<Button variant="outline">Archive logs</Button>
</Tooltip>
</div>
);
}Icon button
The tooltip describes; it does not name. An icon-only button still needs its own aria-label.
Copies a41c9e2 to the clipboard
Code
import { Button } from "@/components/equip/button";
import { Tooltip } from "@/components/equip/tooltip";
export default function Example() {
return (
<Tooltip content="Copies a41c9e2 to the clipboard">
<Button
variant="outline"
aria-label="Copy commit SHA"
lead={
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
<path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2" />
</svg>
}
/>
</Tooltip>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
content | ReactNode | none | The text. Keep it short; a tooltip holds no links or buttons. |
placement | "top" | "bottom" | "top" | Side of the control it shows on. |
delay | number | 400 | Milliseconds the pointer rests on the control before it shows. |
children | ReactNode | none | One focusable control. Its aria-describedby gains the tooltip's id. |
...rest | ComponentProps<"span"> | none | Every native span attribute, applied to the tooltip itself. |
Anatomy
| root | .eq-tooltip | The label, a manual popover in the top layer with role="tooltip". |
|---|---|---|
| trigger | .eq-tooltip-trigger | An inline wrapper around the control; also the anchor. |
Keyboard
| Tab | Focusing the control shows the tooltip at once; moving focus away hides it. |
|---|---|
| Escape | Hides the tooltip without closing the dialog or popover around it. |
Notes
- It is rendered in the top layer, so an overflow: hidden ancestor cannot clip it.
- The control's aria-describedby points at the tooltip, so screen readers read it after the control's name.
- It stays open while the pointer moves onto it, and hides 100 ms after the pointer leaves both.
- A disabled button gets no hover or focus events, so it cannot show a tooltip; explain why it is disabled in visible text instead.
- aria-describedby is added after hydration, as is the tooltip itself; with no JavaScript the control simply has none.
- CSS anchor positioning is used inside @supports when the engine has anchor-name, position-area and position-try-fallbacks (Chromium 125, Safari 26 and Firefox 147 on, each listed as partial support). Elsewhere the tooltip is centred on the control from its box when it shows, kept inside the viewport and flipped when there is no room. It does not follow scrolling while open in that path. The exit fade needs transition of overlay, which only Chromium has; elsewhere it fades in but hides at once. popover="manual" is used rather than popover="hint" so behaviour is the same in every engine.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/tooltip.tsx166 lines
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type FocusEvent,
type ReactNode,
type ToggleEvent,
} from "react";
import "./tooltip.css";
export type TooltipProps = Omit<ComponentProps<"span">, "content"> & {
/** The label. Short, plain text: a tooltip holds no links or buttons. */
content: ReactNode;
placement?: "top" | "bottom";
/** Milliseconds the pointer rests before it shows. Keyboard focus shows it at once. */
delay?: number;
/** One focusable control. It gets aria-describedby pointing at the tooltip. */
children: ReactNode;
};
/* Must match the @supports condition in tooltip.css exactly. */
const ANCHOR_SUPPORT = "(anchor-name: --a) and (position-area: top) and (position-try-fallbacks: flip-block)";
function anchorSupported() {
return typeof CSS !== "undefined" && CSS.supports(ANCHOR_SUPPORT);
}
/** Fallback for engines without anchor positioning: centred on the trigger, kept in the viewport. */
function place(trigger: Element, bubble: HTMLElement, placement: "top" | "bottom") {
const box = trigger.getBoundingClientRect();
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
const gap = parseFloat(getComputedStyle(bubble).marginTop) || 0;
const height = bubble.offsetHeight + gap;
let top = placement === "top";
if (bubble.offsetHeight > 0) {
if (top && box.top - height < 0 && box.bottom + height <= viewHeight) top = false;
else if (!top && box.bottom + height > viewHeight && box.top - height >= 0) top = true;
}
const edge = 4;
const left = box.left + box.width / 2 - bubble.offsetWidth / 2;
const style = bubble.style;
style.left = `${Math.max(edge, Math.min(left, viewWidth - bubble.offsetWidth - edge))}px`;
style.right = "auto";
style.top = top ? "auto" : `${box.bottom}px`;
style.bottom = top ? `${viewHeight - box.top}px` : "auto";
}
export function Tooltip({
content,
placement = "top",
delay = 400,
className,
style,
children,
onToggle,
onMouseEnter,
onMouseLeave,
...rest
}: TooltipProps) {
const id = useId();
const anchorName = `--eq-tooltip-${id.replace(/[^\w-]/g, "")}`;
const trigger = useRef<HTMLSpanElement>(null);
const timer = useRef<number | undefined>(undefined);
const [open, setOpen] = useState(false);
// Describe the control itself, not the wrapper, so it is read when the control takes focus.
useEffect(() => {
const control = trigger.current?.firstElementChild;
if (!control) return;
const own = (control.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean);
if (!own.includes(id)) control.setAttribute("aria-describedby", [...own, id].join(" "));
return () => {
const left = (control.getAttribute("aria-describedby") ?? "")
.split(/\s+/)
.filter((token) => token && token !== id);
if (left.length) control.setAttribute("aria-describedby", left.join(" "));
else control.removeAttribute("aria-describedby");
};
}, [id]);
useEffect(() => () => window.clearTimeout(timer.current), []);
// Escape hides it without also closing a dialog or popover it sits in.
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
event.preventDefault();
hide();
}
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
});
function later(action: () => void, ms: number) {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(action, ms);
}
function show() {
window.clearTimeout(timer.current);
const bubble = document.getElementById(id);
const anchor = trigger.current?.firstElementChild ?? trigger.current;
if (!bubble || !anchor || bubble.matches(":popover-open")) return;
const fallback = !anchorSupported();
if (fallback) place(anchor, bubble, placement);
bubble.showPopover();
// Second pass now that the bubble has a size, to centre and flip it.
if (fallback) place(anchor, bubble, placement);
}
function hide() {
window.clearTimeout(timer.current);
const bubble = document.getElementById(id);
if (bubble?.matches(":popover-open")) bubble.hidePopover();
}
function handleFocus(event: FocusEvent<HTMLSpanElement>) {
// Keyboard focus shows it at once; a mouse click already had the hover.
if (event.target.matches(":focus-visible")) show();
}
return (
<>
<span
ref={trigger}
className="eq-tooltip-trigger"
style={{ anchorName }}
onMouseEnter={() => later(show, delay)}
onMouseLeave={() => later(hide, 100)}
onFocus={handleFocus}
onBlur={hide}
>
{children}
</span>
<span
{...rest}
id={id}
role="tooltip"
popover="manual"
className={["eq-tooltip", className].filter(Boolean).join(" ")}
data-placement={placement}
style={{ positionAnchor: anchorName, ...style }}
onToggle={(event: ToggleEvent<HTMLSpanElement>) => {
onToggle?.(event);
setOpen(event.newState === "open");
}}
onMouseEnter={(event) => {
onMouseEnter?.(event);
window.clearTimeout(timer.current);
}}
onMouseLeave={(event) => {
onMouseLeave?.(event);
later(hide, 100);
}}
>
{content}
</span>
</>
);
}components/equip/tooltip.css99 lines
@layer equip {
.eq-tooltip-trigger {
display: inline-flex;
vertical-align: middle;
}
.eq-tooltip {
--_shift: 4px;
box-sizing: border-box;
/* Undo the UA's centring: the label is placed against its trigger. */
position: fixed;
inset: auto;
margin: var(--eq-space-1) 0;
width: max-content;
max-width: min(240px, calc(100vw - 2 * var(--eq-space-2)));
padding: var(--eq-space-1) var(--eq-space-2);
overflow: visible;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-xs) / 1.4 var(--eq-font);
text-align: center;
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
/* Grows out of the corner nearest its trigger. Leaving is quick, on the plain ease. */
transform-origin: bottom center;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-tooltip[data-placement="bottom"] {
--_shift: -4px;
}
.eq-tooltip[data-placement="bottom"] {
transform-origin: top center;
}
.eq-tooltip:popover-open {
opacity: 1;
scale: none;
translate: none;
/* Arriving: the spring carries it in and lets it settle. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-tooltip:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
}
}
/* Keeping the surface in the top layer while it fades out needs overlay, which only some
engines can transition. Elsewhere it closes at once rather than fade outside the top layer. */
@supports (overlay: auto) {
.eq-tooltip {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-tooltip:popover-open {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
/* Anchor positioning where the engine has it. tooltip.tsx uses the same condition. */
@supports (anchor-name: --a) and (position-area: top) and (position-try-fallbacks: flip-block) {
.eq-tooltip {
position-area: top;
position-try-fallbacks: flip-block;
}
.eq-tooltip[data-placement="bottom"] {
position-area: bottom;
}
}
}