## Tooltip Group — Overlay Delayed once, instant after that. Docs: https://www.interior.dev/docs/tooltip-group Reference: https://www.interior.dev/reference/tooltip-group License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/tooltip-group.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { Tooltip, TooltipGroup } from "@/components/interior/tooltip-group"; export function EditorToolbar({ onFormat, }: { onFormat: (mark: "bold" | "italic" | "link") => void; }) { return ( ); } ``` ### Props - `TooltipGroup.children` (`React.ReactNode`) The triggers that share one delay. Any depth of nesting works; the group is a context, not a layout. - `TooltipGroup.openDelay` (`number`) — default: `200` Milliseconds a cold pointer must rest on a trigger before its tooltip opens. - `TooltipGroup.closeDelay` (`number`) — default: `120` Grace period after the pointer leaves, so crossing a 2px gap between two triggers does not blink. - `TooltipGroup.skipDelay` (`number`) — default: `400` How long the group stays warm after the last tooltip closes. While warm, openDelay is zero. - `TooltipGroup.onWarmChange` (`(warm: boolean) => void`) Fires once per transition between cold and warm. Never fires per frame. - `TooltipGroup.className` (`string`) When set, the group renders one wrapper div with these classes. When omitted it renders no element at all. - `Tooltip.label` (`React.ReactNode`) The tooltip body. Wraps at 220px rather than running off the viewport. - `Tooltip.children` (`React.ReactElement`) A single focusable element. Its own handlers are called before the tooltip's, never replaced. - `Tooltip.side` (`"top" | "bottom"`) — default: `"top"` Which edge the tooltip leaves from. transformOrigin and the 4px entry offset follow it. - `Tooltip.disabled` (`boolean`) — default: `false` Suppresses opening and closes an already-open tooltip on the next commit. - `Tooltip.openDelay` (`number`) — default: `200` Used only when the Tooltip sits outside a TooltipGroup, where it keeps a private store. - `Tooltip.closeDelay` (`number`) — default: `120` Ungrouped fallback timing, matching the group default. - `Tooltip.skipDelay` (`number`) — default: `400` Ungrouped fallback timing, matching the group default. - `Tooltip.className` (`string`) Applied to the relative wrapper span, appended last. - `Tooltip.contentClassName` (`string`) Applied to the tooltip surface, appended last. ### Behavior notes - The delay is charged once per visit, not once per trigger: after the first tooltip opens, every sibling in the group opens on contact until the pointer has been away for skipDelay, so a toolbar sweep stops feeling like five separate waits. - Two tooltips can never be on screen at once; the group holds a single active id, so an outgoing close timer and an incoming open cannot overlap into a double reading. - The tooltip is absolutely positioned against its trigger and mounts outside the flow, so opening one moves nothing — the toolbar keeps its width and the row below keeps its baseline. - Crossing the gap between two triggers costs nothing: closeDelay holds the old tooltip while the next pointerenter cancels the close, which is what stops the flicker on a 2px seam. - Focus opens the tooltip only when the browser reports :focus-visible, so clicking a button does not leave a tooltip parked over the thing you just clicked, and Escape dismisses it and blocks that trigger until the pointer actually leaves. - aria-describedby is attached only while the tooltip is mounted, so a screen reader reads the label once on focus rather than on every pointer pass, and reduced motion keeps the tooltip and drops only the blur and the travel. ### Source (`components/interior/tooltip-group.tsx`) ```tsx "use client"; import { cloneElement, createContext, useContext, useEffect, useId, useRef, useSyncExternalStore, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const LEAVE = [0.4, 0, 1, 1] as const; const RISE = { type: "spring", stiffness: 560, damping: 34, mass: 0.6 } as const; const WARM = { type: "spring", stiffness: 900, damping: 48, mass: 0.5 } as const; const GLIDE = { type: "spring", stiffness: 520, damping: 40, mass: 0.75 } as const; const SWAP = { type: "spring", stiffness: 700, damping: 44, mass: 0.5 } as const; let groups = 0; const stop = (t: Timer): Timer => { if (t !== null) clearTimeout(t); return null; }; export type TooltipTiming = { openDelay: number; closeDelay: number; skipDelay: number; }; type Timer = ReturnType | null; type TooltipStore = { seat: string; subscribe: (fn: () => void) => () => void; getActive: () => string | null; getWarm: () => boolean; getSkipped: () => boolean; getTravel: () => number; open: (id: string, immediate: boolean, x?: number) => void; close: (id: string, immediate: boolean) => void; dismiss: (id: string) => void; unblock: (id: string) => void; reset: () => void; dispose: () => void; }; function createTooltipStore(getTiming: () => TooltipTiming): TooltipStore { const listeners = new Set<() => void>(); let active: string | null = null; let pending: string | null = null; let blocked: string | null = null; let warm = false; let skipped = false; let lastX: number | null = null; let travel = 0; let openTimer: Timer = null; let closeTimer: Timer = null; let coolTimer: Timer = null; const notify = () => { for (const fn of listeners) fn(); }; const setActive = (next: string | null) => { if (active === next) return; if (next !== null) { skipped = warm; warm = true; } active = next; notify(); }; const cool = () => { coolTimer = stop(coolTimer); const { skipDelay } = getTiming(); if (skipDelay <= 0) { if (warm) { warm = false; notify(); } return; } coolTimer = setTimeout(() => { coolTimer = null; warm = false; notify(); }, skipDelay); }; groups += 1; const seat = `tooltip-seat-${groups}`; return { seat, subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, getActive: () => active, getWarm: () => warm, getSkipped: () => skipped, getTravel: () => travel, open(id, immediate, x) { if (blocked === id) return; closeTimer = stop(closeTimer); coolTimer = stop(coolTimer); if (active === id) { openTimer = stop(openTimer); pending = null; return; } const arrive = () => { travel = lastX !== null && x !== undefined ? Math.sign(x - lastX) : 0; lastX = x ?? null; setActive(id); }; if (immediate || warm) { openTimer = stop(openTimer); pending = null; arrive(); return; } openTimer = stop(openTimer); pending = id; openTimer = setTimeout(() => { openTimer = null; pending = null; arrive(); }, getTiming().openDelay); }, close(id, immediate) { if (pending === id) { openTimer = stop(openTimer); pending = null; } if (active !== id) return; closeTimer = stop(closeTimer); const finish = () => { closeTimer = null; setActive(null); cool(); }; if (immediate || getTiming().closeDelay <= 0) { finish(); return; } closeTimer = setTimeout(finish, getTiming().closeDelay); }, dismiss(id) { blocked = id; openTimer = stop(openTimer); closeTimer = stop(closeTimer); coolTimer = stop(coolTimer); pending = null; const wasWarm = warm; warm = false; if (active === id) setActive(null); else if (wasWarm) notify(); }, unblock(id) { if (blocked === id) blocked = null; }, reset() { openTimer = stop(openTimer); closeTimer = stop(closeTimer); coolTimer = stop(coolTimer); pending = null; blocked = null; lastX = null; travel = 0; const wasWarm = warm; warm = false; if (active !== null) setActive(null); else if (wasWarm) notify(); }, dispose() { openTimer = stop(openTimer); closeTimer = stop(closeTimer); coolTimer = stop(coolTimer); listeners.clear(); }, }; } const TooltipGroupContext = createContext(null); function useDismissOnBlur(store: TooltipStore, enabled: boolean) { useEffect(() => { if (!enabled) return; const bail = () => store.reset(); const onVisibility = () => { if (document.hidden) store.reset(); }; window.addEventListener("blur", bail); document.addEventListener("visibilitychange", onVisibility); return () => { window.removeEventListener("blur", bail); document.removeEventListener("visibilitychange", onVisibility); }; }, [store, enabled]); } export type TooltipGroupProps = { children: React.ReactNode; openDelay?: number; closeDelay?: number; skipDelay?: number; onWarmChange?: (warm: boolean) => void; className?: string; }; export function TooltipGroup({ children, openDelay = 200, closeDelay = 120, skipDelay = 400, onWarmChange, className = "", }: TooltipGroupProps) { const timing = useRef({ openDelay, closeDelay, skipDelay }); timing.current = { openDelay, closeDelay, skipDelay }; const held = useRef(null); if (held.current === null) { held.current = createTooltipStore(() => timing.current); } const store = held.current; const warm = useSyncExternalStore( store.subscribe, store.getWarm, () => false, ); const report = useRef(onWarmChange); report.current = onWarmChange; useEffect(() => { report.current?.(warm); }, [warm]); useEffect(() => () => store.dispose(), [store]); useDismissOnBlur(store, true); return ( {className ?
{children}
: children}
); } export type UseTooltipOptions = { disabled?: boolean; openDelay?: number; closeDelay?: number; skipDelay?: number; }; export type TooltipTriggerProps = { onPointerEnter: (event: React.PointerEvent) => void; onPointerLeave: (event: React.PointerEvent) => void; onPointerDown: (event: React.PointerEvent) => void; onPointerCancel: (event: React.PointerEvent) => void; onFocus: (event: React.FocusEvent) => void; onBlur: (event: React.FocusEvent) => void; onKeyDown: (event: React.KeyboardEvent) => void; }; export type UseTooltipReturn = { open: boolean; warm: boolean; skipped: boolean; travel: number; tooltipId: string; seat: string; triggerProps: TooltipTriggerProps; }; function isKeyboardFocus(el: HTMLElement) { try { return el.matches(":focus-visible"); } catch { return true; } } export function useTooltip({ disabled = false, openDelay = 200, closeDelay = 120, skipDelay = 400, }: UseTooltipOptions = {}): UseTooltipReturn { const tooltipId = `tt-${useId()}`; const group = useContext(TooltipGroupContext); const timing = useRef({ openDelay, closeDelay, skipDelay }); timing.current = { openDelay, closeDelay, skipDelay }; const solo = useRef(null); if (group === null && solo.current === null) { solo.current = createTooltipStore(() => timing.current); } const store = group ?? (solo.current as TooltipStore); useEffect(() => { const own = solo.current; return () => { store.close(tooltipId, true); own?.dispose(); }; }, [store, tooltipId]); useDismissOnBlur(store, group === null); const open = useSyncExternalStore( store.subscribe, () => store.getActive() === tooltipId, () => false, ); const warm = useSyncExternalStore( store.subscribe, store.getWarm, () => false, ); const skipped = useSyncExternalStore( store.subscribe, store.getSkipped, () => false, ); const travel = useSyncExternalStore( store.subscribe, store.getTravel, () => 0, ); useEffect(() => { if (!disabled) return; store.close(tooltipId, true); }, [disabled, store, tooltipId]); const triggerProps: TooltipTriggerProps = { onPointerEnter: (event) => { if (!disabled) store.open(tooltipId, false, event.clientX); }, onPointerLeave: () => { store.unblock(tooltipId); store.close(tooltipId, false); }, onPointerDown: () => store.dismiss(tooltipId), onPointerCancel: () => { store.unblock(tooltipId); store.close(tooltipId, true); }, onFocus: (event) => { if (disabled) return; if (!isKeyboardFocus(event.currentTarget)) return; store.open(tooltipId, true); }, onBlur: () => { store.unblock(tooltipId); store.close(tooltipId, true); }, onKeyDown: (event) => { if (event.key === "Escape") store.dismiss(tooltipId); }, }; return { open, warm, skipped, travel, tooltipId, seat: store.seat, triggerProps }; } type TriggerChild = React.ReactElement< React.HTMLAttributes & { "aria-describedby"?: string } >; export type TooltipProps = UseTooltipOptions & { label: React.ReactNode; children: TriggerChild; side?: "top" | "bottom"; className?: string; contentClassName?: string; }; function chain( theirs: ((event: E) => void) | undefined, ours: (event: E) => void, ) { return (event: E) => { theirs?.(event); ours(event); }; } export function Tooltip({ label, children, side = "top", disabled = false, openDelay, closeDelay, skipDelay, className = "", contentClassName = "", }: TooltipProps) { const { open, skipped, travel, tooltipId, seat, triggerProps } = useTooltip({ disabled, openDelay, closeDelay, skipDelay, }); const reduced = useReducedMotion(); const described = [children.props["aria-describedby"], open ? tooltipId : null] .filter(Boolean) .join(" "); const trigger = cloneElement(children, { "aria-describedby": described.length > 0 ? described : undefined, onPointerEnter: chain( children.props.onPointerEnter, triggerProps.onPointerEnter, ), onPointerLeave: chain( children.props.onPointerLeave, triggerProps.onPointerLeave, ), onPointerDown: chain( children.props.onPointerDown, triggerProps.onPointerDown, ), onPointerCancel: chain( children.props.onPointerCancel, triggerProps.onPointerCancel, ), onFocus: chain(children.props.onFocus, triggerProps.onFocus), onBlur: chain(children.props.onBlur, triggerProps.onBlur), onKeyDown: chain(children.props.onKeyDown, triggerProps.onKeyDown), }); const lift = side === "top" ? 7 : -7; return ( {trigger} {open && ( {label} )} ); } ```