Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/tooltip.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
tooltip.tsx. It's yours: change anything.
Usage
import { Tooltip, TooltipGroup } from "@/components/tooltip"
export function Toolbar() {
return (
<TooltipGroup>
<div role="toolbar" aria-label="Formatting" className="flex gap-1">
<Tooltip content="Bold (⌘B)">
<button type="button" aria-label="Bold">B</button>
</Tooltip>
<Tooltip content="Italic (⌘I)">
<button type="button" aria-label="Italic">I</button>
</Tooltip>
</div>
</TooltipGroup>
)
}Source
One file, 192 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TooltipProps extends Omit<React.ComponentProps<"span">, "content"> {
/** The hint. Keep it short and non-essential: touch screens never see it. */
content: React.ReactNode;
/** One focusable element, e.g. an icon button. It is described by the tooltip. */
children: React.ReactElement<{ "aria-describedby"?: string }>;
side?: "top" | "bottom" | "left" | "right";
/** Milliseconds the pointer must rest before it opens. Keyboard focus opens it at once. */
delay?: number;
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
// How long after a tooltip closes its neighbours open without waiting.
const WARM_MS = 500;
// A short grace before closing, so moving to a neighbour swaps the tooltip in one step.
const GRACE_MS = 80;
const placement = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
} as const;
const arrow = {
top: "left-1/2 top-full -translate-x-1/2 -translate-y-1/2",
bottom: "left-1/2 bottom-full -translate-x-1/2 translate-y-1/2",
left: "top-1/2 left-full -translate-x-1/2 -translate-y-1/2",
right: "top-1/2 right-full translate-x-1/2 -translate-y-1/2",
} as const;
// It grows out of the side that faces its trigger.
const from = {
top: { y: 6, originX: 0.5, originY: 1 },
bottom: { y: -6, originX: 0.5, originY: 0 },
left: { x: 6, originX: 1, originY: 0.5 },
right: { x: -6, originX: 0, originY: 0.5 },
} as const;
type Group = { active: string | null; open: (id: string) => void; close: (id: string) => void; isWarm: () => boolean; layoutId: string };
const GroupContext = React.createContext<Group | null>(null);
/**
* Wrap a row of tooltips (a toolbar): once one is open, the next opens at
* once, and the same bubble glides from trigger to trigger instead of
* blinking out and in.
*/
export function TooltipGroup({ children }: { children: React.ReactNode }) {
const [active, setActive] = React.useState<string | null>(null);
const warmUntil = React.useRef(0);
const layoutId = React.useId();
const value = React.useMemo<Group>(
() => ({
active,
layoutId,
open: (id) => setActive(id),
close: (id) => {
warmUntil.current = performance.now() + WARM_MS;
// Only close if a neighbour hasn't taken over meanwhile.
setActive((current) => (current === id ? null : current));
},
isWarm: () => performance.now() < warmUntil.current,
}),
[active, layoutId],
);
return (
<GroupContext.Provider value={value}>
<LayoutGroup id={layoutId}>{children}</LayoutGroup>
</GroupContext.Provider>
);
}
/**
* A small hint that appears beside an element on hover or keyboard focus,
* springing out of the side that faces it.
*
* - The trigger is described by the tooltip (`aria-describedby`), and the
* description is always in the page, so screen readers hear it as soon as
* focus lands.
* - Meets WCAG 1.4.13: Escape dismisses it, and the pointer can move onto it
* without it closing.
* - Inside a `TooltipGroup`, neighbours open without a delay and one bubble
* glides between them.
* - Positioned with CSS on the side you choose; it doesn't flip at screen
* edges, so pick a side with room. Instant under reduced motion.
*/
export function Tooltip({ content, children, side = "top", delay = 300, className, ...props }: TooltipProps) {
const id = React.useId();
const group = React.useContext(GroupContext);
const reduceMotion = useReducedMotion();
const [ownOpen, setOwnOpen] = React.useState(false);
const open = group ? group.active === id : ownOpen;
const timer = React.useRef<number | undefined>(undefined);
const setOpen = React.useCallback(
(next: boolean) => {
if (!group) setOwnOpen(next);
else if (next) group.open(id);
else group.close(id);
},
[group, id],
);
const show = (after: number) => {
window.clearTimeout(timer.current);
const warm = group && (group.active !== null || group.isWarm());
if (warm || after === 0) setOpen(true);
else timer.current = window.setTimeout(() => setOpen(true), after);
};
const hide = (after = 0) => {
window.clearTimeout(timer.current);
if (after) timer.current = window.setTimeout(() => setOpen(false), after);
else setOpen(false);
};
React.useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, setOpen]);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
const describedBy = [children.props["aria-describedby"], id].filter(Boolean).join(" ");
const instant = reduceMotion ? { duration: 0 } : undefined;
const { originX, originY, ...offset } = from[side];
return (
<span
className={cn("relative inline-flex", className)}
data-state={open ? "open" : "closed"}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") show(delay);
}}
onPointerLeave={() => hide(group ? GRACE_MS : 0)}
onFocus={(event) => {
// Keyboard focus only; a click's focus shouldn't pop a hint over what was just clicked.
if (event.target.matches(":focus-visible")) show(0);
}}
onBlur={() => hide()}
{...props}
>
{React.cloneElement(children, { "aria-describedby": describedBy })}
{/* What assistive technology reads: always present, never shown. */}
<span id={id} role="tooltip" className="sr-only">
{content}
</span>
<AnimatePresence initial={false}>
{open && (
<motion.span
key="tip"
aria-hidden="true"
layoutId={group?.layoutId}
className={cn(
"absolute z-50 block w-max max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium leading-snug text-background shadow-lg text-pretty",
placement[side],
)}
style={{ originX, originY, borderRadius: 6 }}
initial={{ opacity: 0, scale: 0.85, ...offset }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={{ opacity: 0, scale: 0.9, ...offset, transition: instant ?? { duration: 0.1 } }}
transition={instant ?? SPRING_SNAPPY}
>
<motion.span
key={id}
layout="position"
className="relative z-10 block"
initial={group ? { opacity: 0, filter: "blur(2px)" } : false}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={instant ?? SPRING_SMOOTH}
>
{content}
</motion.span>
<span className={cn("absolute size-2 rotate-45 rounded-[1px] bg-foreground", arrow[side])} />
</motion.span>
)}
</AnimatePresence>
</span>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| content | ReactNode | — | The hint. Keep it short and non-essential: touch screens never see it. |
| children | ReactElement | — | One focusable element; it is described by the tooltip. |
| side | "top" | "bottom" | "left" | "right" | "top" | Where it appears. It doesn't flip at screen edges. |
| delay | number | 300 | Milliseconds the pointer rests before it opens. Keyboard focus opens it at once. |
| TooltipGroup | component | — | Wrap a row of tooltips: neighbours skip the delay and one bubble glides between them. |
Accessibility
- The trigger is described by the tooltip with aria-describedby, kept alongside any description it already has; the description is always in the page, so it is read as soon as focus lands.
- Opens on keyboard focus, not on a click's focus; Escape closes it.
- The pointer can move onto the hint without it closing (WCAG 1.4.13).
- Never put essential information in a tooltip: touch users can't open it.
- Under prefers-reduced-motion it appears and moves without animation.
Changelog
- v1.0.0Latest
First release.