## 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 ?