"use client";
import * as React from "react";
import { animate, type AnimationPlaybackControls } from "motion/react";
type Side = "top" | "bottom" | "left" | "right";
export interface SmartTooltipProps {
/** Tooltip text. Keep it short and non-interactive (no links or buttons — use a popover for those). */
content: React.ReactNode;
/** The trigger: one focusable element. It receives aria-describedby pointing at the tooltip. */
children: React.ReactElement<React.HTMLAttributes<HTMLElement>>;
/** Preferred side; flips to the opposite side when there is no room. */
side?: Side;
/** Alignment along the trigger; shifted to stay inside the viewport. */
align?: "start" | "center" | "end";
/** Hover delay before opening, in ms. Skipped while another tooltip is (or was just) open. */
delay?: number;
/** Grace period after the pointer leaves, so it can move onto the tooltip, in ms. */
closeDelay?: number;
/** After a tooltip closes, neighbours open instantly for this long, in ms. */
skipDelayDuration?: number;
/** Distance between trigger and tooltip in px. */
sideOffset?: number;
/** Show the pointing arrow. */
arrow?: boolean;
variant?: "contrast" | "surface" | "accent";
/** Maximum width in px. */
maxWidth?: number;
/** Touch: press-and-hold duration that shows the tooltip, in ms. */
longPressDelay?: number;
/** Controlled open state. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Accent colour for the accent variant. */
accent?: string;
/** Extra classes for the tooltip bubble. */
className?: string;
}
const EDGE = 8; // px kept from the viewport edge
const ARROW = 5; // px arrow half-size
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
/* ------------------------------- delay group ------------------------------- */
// Module-level, like the operating system: once one tooltip is open, the next opens instantly.
const group = {
openId: null as string | null,
close: null as (() => void) | null,
lastClosedAt: -Infinity,
};
function groupOpen(id: string, close: () => void) {
if (group.openId && group.openId !== id) group.close?.();
group.openId = id;
group.close = close;
}
function groupClosed(id: string) {
if (group.openId !== id) return;
group.openId = null;
group.close = null;
group.lastClosedAt = performance.now();
}
function groupIsWarm(skip: number) {
return group.openId !== null || performance.now() - group.lastClosedAt < skip;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const VARIANTS = {
contrast: "bg-zinc-900 text-zinc-50 dark:bg-zinc-50 dark:text-zinc-900",
surface:
"border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#1f2328] dark:text-zinc-100",
accent: "bg-[var(--st-accent)] text-zinc-950",
} as const;
/**
* An accessible tooltip that opens on hover (after a delay) and on keyboard focus, and meets WCAG
* 1.4.13: the pointer can move onto it (hoverable), Escape hides it without moving focus
* (dismissible), and it stays until hover or focus leaves (persistent). It renders in the top layer,
* flips and shifts to stay in view with an arrow that keeps pointing at the trigger, and shares a
* delay group so neighbouring tooltips open instantly. Touch: press and hold. Not for interactive content.
*/
export function SmartTooltip({
content,
children,
side = "top",
align = "center",
delay = 600,
closeDelay = 120,
skipDelayDuration = 300,
sideOffset = 8,
arrow = true,
variant = "contrast",
maxWidth = 240,
longPressDelay = 500,
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
accent = "#FF7448",
className = "",
}: SmartTooltipProps) {
const uid = React.useId();
const tooltipId = `${uid}-tooltip`;
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState(defaultOpen);
const isOpen = !disabled && (openProp ?? internal);
const [instant, setInstant] = React.useState(false);
const wrapRef = React.useRef<HTMLSpanElement>(null);
const tipRef = React.useRef<HTMLSpanElement>(null);
const timers = React.useRef({ open: 0, close: 0, press: 0 });
const press = React.useRef({ x: 0, y: 0, opened: false });
// Set after a click or Escape: stays closed until the pointer leaves / focus moves.
const suppressed = React.useRef(false);
const animation = React.useRef<AnimationPlaybackControls | null>(null);
const generation = React.useRef(0);
const onOpenChangeRef = React.useRef(onOpenChange);
React.useEffect(() => {
onOpenChangeRef.current = onOpenChange;
});
const setOpen = (next: boolean, skipAnimation = false) => {
window.clearTimeout(timers.current.open);
window.clearTimeout(timers.current.close);
if (next === isOpen) return;
setInstant(skipAnimation);
if (openProp === undefined) setInternal(next);
onOpenChange?.(next);
};
const scheduleOpen = (immediate: boolean) => {
if (disabled || suppressed.current) return;
window.clearTimeout(timers.current.close);
window.clearTimeout(timers.current.open);
const warm = groupIsWarm(skipDelayDuration);
if (immediate || warm) setOpen(true, warm && !immediate);
else timers.current.open = window.setTimeout(() => setOpen(true), delay);
};
const scheduleClose = () => {
window.clearTimeout(timers.current.open);
window.clearTimeout(timers.current.close);
timers.current.close = window.setTimeout(() => setOpen(false), closeDelay);
};
/** Positions the bubble: preferred side, flipped when it doesn't fit, shifted inside the viewport. */
const position = React.useCallback(() => {
const tip = tipRef.current;
const wrap = wrapRef.current;
if (!tip || !wrap) return;
const anchor = (wrap.firstElementChild as HTMLElement | null) ?? wrap;
const r = anchor.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = tip.offsetWidth;
const h = tip.offsetHeight;
const gap = sideOffset + (arrow ? ARROW - 1 : 0);
const fits = (s: Side) =>
s === "top" ? r.top - gap - h >= EDGE : s === "bottom" ? r.bottom + gap + h <= vh - EDGE : s === "left" ? r.left - gap - w >= EDGE : r.right + gap + w <= vw - EDGE;
const finalSide = fits(side) || !fits(OPPOSITE[side]) ? side : OPPOSITE[side];
const vertical = finalSide === "top" || finalSide === "bottom";
let x: number;
let y: number;
if (vertical) {
x = align === "start" ? r.left : align === "end" ? r.right - w : r.left + r.width / 2 - w / 2;
y = finalSide === "top" ? r.top - gap - h : r.bottom + gap;
} else {
y = align === "start" ? r.top : align === "end" ? r.bottom - h : r.top + r.height / 2 - h / 2;
x = finalSide === "left" ? r.left - gap - w : r.right + gap;
}
x = Math.max(EDGE, Math.min(x, vw - w - EDGE));
y = Math.max(EDGE, Math.min(y, vh - h - EDGE));
// The arrow keeps pointing at the trigger's centre even after shifting.
const arrowAt = vertical ? Math.max(10, Math.min(w - 10, r.left + r.width / 2 - x)) : Math.max(8, Math.min(h - 8, r.top + r.height / 2 - y));
tip.style.left = `${Math.round(x)}px`;
tip.style.top = `${Math.round(y)}px`;
tip.style.setProperty("--st-arrow", `${Math.round(arrowAt)}px`);
tip.dataset.side = finalSide;
}, [side, align, sideOffset, arrow]);
// Show / hide the top-layer bubble and animate it in from the trigger side.
React.useLayoutEffect(() => {
const tip = tipRef.current;
if (!tip) return;
animation.current?.stop();
const run = ++generation.current;
if (isOpen) {
if (!tip.matches(":popover-open")) {
try {
tip.showPopover();
} catch {
// Popover API unavailable: position: fixed still applies.
}
}
tip.style.visibility = "visible";
position();
const s = tip.dataset.side as Side;
const d = 4;
const offset = s === "top" ? { y: [d, 0] } : s === "bottom" ? { y: [-d, 0] } : s === "left" ? { x: [d, 0] } : { x: [-d, 0] };
animation.current =
reduceMotion || instant
? animate(tip, { opacity: [0, 1], x: 0, y: 0, scale: 1 }, { duration: instant ? 0.06 : 0.12 })
: animate(tip, { opacity: [0, 1], scale: [0.96, 1], ...offset }, { type: "spring", stiffness: 520, damping: 34, opacity: { duration: 0.12 } });
return;
}
if (tip.style.visibility !== "visible") return;
animation.current = animate(tip, { opacity: 0 }, { duration: instant ? 0.04 : 0.1 });
void animation.current.finished.then(() => {
// A newer show / hide has taken over.
if (generation.current !== run) return;
tip.style.visibility = "hidden";
try {
tip.hidePopover();
} catch {
// not shown as a popover
}
});
}, [isOpen, instant, reduceMotion, position]);
// Join the delay group while open; Escape dismisses; scroll / resize re-position; outside taps close (touch).
React.useEffect(() => {
if (!isOpen) return;
const close = () => {
if (openProp === undefined) setInternal(false);
onOpenChangeRef.current?.(false);
};
groupOpen(uid, () => {
setInstant(true);
close();
});
let frame = 0;
// Escape closes only the tooltip: handled first (window, capture phase) and consumed, so it
// doesn't also close a surrounding dialog or menu. preventDefault stops a <dialog>'s cancel.
// While no tooltip is open this listener isn't attached and Escape passes through untouched.
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
suppressed.current = true;
setInstant(false);
close();
};
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(position);
};
const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== "mouse" && !wrapRef.current?.contains(event.target as Node)) close();
};
window.addEventListener("keydown", onKey, true);
document.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
return () => {
groupClosed(uid);
cancelAnimationFrame(frame);
window.removeEventListener("keydown", onKey, true);
document.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
};
}, [isOpen, uid, openProp, position]);
React.useEffect(() => {
const t = timers.current;
return () => {
window.clearTimeout(t.open);
window.clearTimeout(t.close);
window.clearTimeout(t.press);
animation.current?.stop();
};
}, []);
const describedBy = [children.props["aria-describedby"], disabled ? undefined : tooltipId].filter(Boolean).join(" ") || undefined;
const trigger = React.cloneElement(children, { "aria-describedby": describedBy });
return (
<span
ref={wrapRef}
className="inline-flex [-webkit-touch-callout:none]"
style={{ ["--st-accent" as string]: accent }}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") scheduleOpen(false);
}}
onPointerLeave={(event) => {
if (event.pointerType !== "mouse") return;
suppressed.current = false;
scheduleClose();
}}
onPointerDown={(event) => {
if (event.pointerType === "mouse") {
// Clicking the trigger hides the tooltip, like a native one.
suppressed.current = true;
setOpen(false);
return;
}
window.clearTimeout(timers.current.press);
press.current = { x: event.clientX, y: event.clientY, opened: false };
timers.current.press = window.setTimeout(() => {
press.current.opened = true;
suppressed.current = false;
setOpen(true);
}, longPressDelay);
}}
onPointerMove={(event) => {
if (event.pointerType === "mouse") return;
if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 10) window.clearTimeout(timers.current.press);
}}
onPointerUp={() => window.clearTimeout(timers.current.press)}
onPointerCancel={() => window.clearTimeout(timers.current.press)}
onContextMenu={(event) => {
// A long-press showed the tooltip; don't also open the system menu.
if (press.current.opened) event.preventDefault();
}}
onClickCapture={(event) => {
// Swallow the click that can follow a long-press.
if (press.current.opened) {
press.current.opened = false;
event.preventDefault();
event.stopPropagation();
}
}}
onFocus={(event) => {
// Keyboard focus shows it at once; focus from a click does not.
if (event.target instanceof Element && event.target.matches(":focus-visible")) {
suppressed.current = false;
scheduleOpen(true);
}
}}
onBlur={() => {
suppressed.current = false;
setOpen(false);
}}
>
{trigger}
<span
ref={tipRef}
id={tooltipId}
role="tooltip"
popover="manual"
onPointerEnter={() => window.clearTimeout(timers.current.close)}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") scheduleClose();
}}
className={`group/tip invisible fixed inset-auto m-0 block w-max overflow-visible rounded-lg px-2.5 py-1.5 text-left text-[12.5px] font-medium leading-snug tracking-[-0.005em] opacity-0 ${VARIANTS[variant] ?? VARIANTS.contrast} ${className}`}
style={{ maxWidth: `min(${maxWidth}px, calc(100vw - ${EDGE * 2}px))`, ["--st-accent" as string]: accent }}
>
{content}
{arrow ? (
<span
aria-hidden="true"
className={`pointer-events-none absolute size-2.5 rotate-45 bg-inherit group-data-[side=bottom]/tip:-top-[5px] group-data-[side=bottom]/tip:left-[var(--st-arrow)] group-data-[side=bottom]/tip:-translate-x-1/2 group-data-[side=left]/tip:-right-[5px] group-data-[side=left]/tip:top-[var(--st-arrow)] group-data-[side=left]/tip:-translate-y-1/2 group-data-[side=right]/tip:-left-[5px] group-data-[side=right]/tip:top-[var(--st-arrow)] group-data-[side=right]/tip:-translate-y-1/2 group-data-[side=top]/tip:-bottom-[5px] group-data-[side=top]/tip:left-[var(--st-arrow)] group-data-[side=top]/tip:-translate-x-1/2 ${
variant === "surface"
? "border-zinc-900/10 group-data-[side=bottom]/tip:border-l group-data-[side=bottom]/tip:border-t group-data-[side=left]/tip:border-r group-data-[side=left]/tip:border-t group-data-[side=right]/tip:border-b group-data-[side=right]/tip:border-l group-data-[side=top]/tip:border-b group-data-[side=top]/tip:border-r dark:border-white/10"
: ""
}`}
/>
) : null}
</span>
</span>
);
}