Tooltip
Tooltips that warm up. The first waits for its delay; after that the row is warm, and one tip slides from trigger to trigger, its width springing to the new text while the words cross-fade.
import {
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { FocusEvent, MouseEvent, PointerEvent, ReactElement, ReactNode, Ref } from 'react';
import { SPRING, Spring, createLoop } from '../shared/spring';
import { resolveAccent, isFocusVisible, type Accent } from '../shared/accent';
import { Presence, place, useReducedMotionRef, type Side } from '../overlays-kit/kit';
import '../shared/ember.css';
import './tooltip.css';
export interface TooltipProps {
content: ReactNode;
/** A key hint shown in faint mono after the text. */
shortcut?: string;
side?: Side;
/** Wait before the first tip shows, in ms. Warm tips show at once. */
delay?: number;
/** The trigger: a single element that can take focus. */
children: ReactElement;
/** Used only when the tooltip is not inside a TooltipGroup. */
accent?: Accent;
}
export interface TooltipGroupProps {
children: ReactNode;
accent?: Accent;
className?: string;
}
/** The tip's fixed height: 6px padding, 16px line, 6px padding, 1px border twice. */
const TIP_H = 30;
const GAP = 8;
/** After a tip hides, the group stays warm for this long. */
const WARM_MS = 300;
/** Leaving one trigger for its neighbour should not dip the tip; hold the hide this long. */
const LEAVE_GRACE_MS = 60;
const CROSSFADE_MS = 120;
interface TipRequest {
id: string;
el: HTMLElement;
content: ReactNode;
shortcut?: string;
side: Side;
}
interface Layer {
key: number;
content: ReactNode;
shortcut?: string;
}
interface GroupApi {
tipId: string;
activeId: string | null;
show(req: TipRequest, delay: number, immediate: boolean): void;
hide(id: string, grace: boolean): void;
}
const GroupContext = createContext<GroupApi | null>(null);
const ORIGIN: Record<Side, (ox: number, oy: number) => string> = {
top: (ox) => `${ox}px 100%`,
bottom: (ox) => `${ox}px 0`,
left: (_ox, oy) => `100% ${oy}px`,
right: (_ox, oy) => `0 ${oy}px`,
};
/**
* Shares one tip between every Tooltip inside it. After the first tip has shown, the next one
* appears at once and the same tip slides across instead of fading out and in.
*/
export function TooltipGroup({ children, accent: accentProp, className }: TooltipGroupProps) {
const accent = resolveAccent(accentProp);
const tipId = `${useId()}-tip`;
const [activeId, setActiveId] = useState<string | null>(null);
const [layers, setLayers] = useState<Layer[]>([]);
const reduced = useReducedMotionRef();
const tipRef = useRef<HTMLDivElement>(null);
const layerEls = useRef(new Map<number, HTMLSpanElement>());
const s = useRef({
owner: null as string | null,
req: null as TipRequest | null,
pendingId: null as string | null,
needsPlace: false,
showTimer: 0,
hideTimer: 0,
lastHideAt: -Infinity,
placed: false,
slide: false,
side: 'top' as Side,
x: new Spring(0, SPRING.snappy),
y: new Spring(0, SPRING.snappy),
w: new Spring(0, SPRING.snappy),
presence: new Presence(0.96, SPRING.soft, 120, 100),
fades: new Map<number, number>(),
topKey: 0,
nextKey: 1,
}).current;
const apply = useCallback(() => {
const tip = tipRef.current;
if (!tip) return;
const p = s.presence;
tip.style.visibility = p.visible ? 'visible' : 'hidden';
tip.style.opacity = p.opacity.toFixed(3);
tip.style.width = `${s.w.value.toFixed(2)}px`;
const sc = p.scale.value;
tip.style.transform = `translate3d(${s.x.value.toFixed(2)}px, ${s.y.value.toFixed(2)}px, 0)${sc !== 1 ? ` scale(${sc.toFixed(4)})` : ''}`;
for (const [key, el] of layerEls.current) el.style.opacity = (s.fades.get(key) ?? 0).toFixed(3);
}, [s]);
const loop = useMemo(
() =>
createLoop((dt) => {
s.presence.reduced = reduced.current;
let moving = s.presence.step(dt);
if (s.x.step(dt)) moving = true;
if (s.y.step(dt)) moving = true;
if (s.w.step(dt)) moving = true;
const rate = (dt * 1000) / (reduced.current ? 100 : CROSSFADE_MS);
let dropped = false;
for (const [key, v] of s.fades) {
const target = key === s.topKey ? 1 : 0;
const next = target > v ? Math.min(1, v + rate) : Math.max(0, v - rate);
s.fades.set(key, next);
if (next !== target) moving = true;
else if (target === 0) dropped = true;
}
apply();
if (dropped) setLayers((ls) => ls.filter((l) => l.key === s.topKey || (s.fades.get(l.key) ?? 0) > 0));
if (!moving && s.presence.phase === 'out') setLayers([]);
return moving;
}),
[s, apply, reduced],
);
useEffect(() => () => {
loop.stop();
clearTimeout(s.showTimer);
clearTimeout(s.hideTimer);
}, [loop, s]);
const doHide = useCallback(() => {
clearTimeout(s.hideTimer);
s.hideTimer = 0;
if (!s.owner) return;
s.owner = null;
s.lastHideAt = performance.now();
setActiveId(null);
s.presence.exit();
loop.wake();
}, [s, loop]);
const commit = useCallback(
(req: TipRequest, slide: boolean) => {
clearTimeout(s.showTimer);
clearTimeout(s.hideTimer);
s.showTimer = 0;
s.hideTimer = 0;
const same = s.owner === req.id;
s.owner = req.id;
s.req = req;
s.slide = slide && s.placed;
setActiveId(req.id);
if (same && s.presence.phase === 'in') return;
const key = s.nextKey++;
s.topKey = key;
if (!s.slide) s.fades.clear();
// Warm: the new text fades in over the old one. Cold: the whole tip fades, the text is just there.
s.fades.set(key, s.slide ? 0 : 1);
s.needsPlace = true;
setLayers((ls) => (s.slide ? [...ls.filter((l) => s.fades.has(l.key)), { key, ...req }] : [{ key, ...req }]));
},
[s],
);
// A new layer has rendered: measure it, then place (cold) or slide (warm) the tip to its trigger.
useLayoutEffect(() => {
const req = s.req;
const tip = tipRef.current;
const el = layerEls.current.get(s.topKey);
if (!s.needsPlace || !req || !tip || !el || !s.owner) return;
s.needsPlace = false;
const inner = el.firstElementChild as HTMLElement | null;
const w = Math.ceil((inner?.getBoundingClientRect().width ?? 0) + 22);
const anchor = req.el.getBoundingClientRect();
const pl = place(anchor, w, TIP_H, req.side, 'center', GAP);
s.side = pl.side;
tip.dataset.side = pl.side;
tip.style.transformOrigin = ORIGIN[pl.side](
Math.round(anchor.left + anchor.width / 2 - pl.x),
Math.round(anchor.top + anchor.height / 2 - pl.y),
);
s.presence.reduced = reduced.current;
if (s.slide && !reduced.current) {
s.x.target = pl.x;
s.y.target = pl.y;
s.w.target = w;
} else {
s.x.set(pl.x);
s.y.set(pl.y);
s.w.set(w);
}
if (!s.slide) s.presence.reset();
s.presence.enter();
s.placed = true;
apply();
loop.wake();
}, [layers, s, apply, loop, reduced]);
// While a tip is up: Escape and any scroll hide it.
useEffect(() => {
if (!activeId) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') doHide();
};
const onScroll = () => doHide();
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', onScroll, true);
};
}, [activeId, doHide]);
const api = useMemo<GroupApi>(
() => ({
tipId,
activeId,
show(req, delay, immediate) {
clearTimeout(s.showTimer);
s.showTimer = 0;
s.pendingId = null;
const holding = s.hideTimer !== 0;
const warm = !!s.owner || holding || performance.now() - s.lastHideAt < WARM_MS;
if (immediate || warm) {
commit(req, warm);
return;
}
s.pendingId = req.id;
s.showTimer = window.setTimeout(() => {
s.pendingId = null;
commit(req, false);
}, Math.max(0, delay));
},
hide(id, grace) {
if (s.pendingId === id) {
clearTimeout(s.showTimer);
s.showTimer = 0;
s.pendingId = null;
}
if (s.owner !== id) return;
if (grace) {
clearTimeout(s.hideTimer);
s.hideTimer = window.setTimeout(doHide, LEAVE_GRACE_MS);
} else doHide();
},
}),
[tipId, activeId, s, commit, doHide],
);
return (
<div className={`ember em-tip-group${className ? ` ${className}` : ''}`} data-accent={accent}>
<GroupContext.Provider value={api}>{children}</GroupContext.Provider>
<div ref={tipRef} id={tipId} role="tooltip" className="em-pop em-tip" style={{ visibility: 'hidden', opacity: 0 }}>
{layers.map((l) => (
<span
key={l.key}
ref={(node) => {
if (node) layerEls.current.set(l.key, node);
else layerEls.current.delete(l.key);
}}
className="em-tip__layer"
aria-hidden={l.key === s.topKey ? undefined : true}
style={{ opacity: s.fades.get(l.key) ?? 0 }}
>
<span className="em-tip__content">
<span className="em-tip__text">{l.content}</span>
{l.shortcut ? <kbd className="em-tip__key">{l.shortcut}</kbd> : null}
</span>
</span>
))}
</div>
</div>
);
}
type TriggerProps = {
ref?: Ref<HTMLElement>;
onPointerEnter?: (e: PointerEvent<HTMLElement>) => void;
onPointerLeave?: (e: PointerEvent<HTMLElement>) => void;
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
onPointerUp?: (e: PointerEvent<HTMLElement>) => void;
onPointerCancel?: (e: PointerEvent<HTMLElement>) => void;
onClick?: (e: MouseEvent<HTMLElement>) => void;
onContextMenu?: (e: MouseEvent<HTMLElement>) => void;
onFocus?: (e: FocusEvent<HTMLElement>) => void;
onBlur?: (e: FocusEvent<HTMLElement>) => void;
'aria-describedby'?: string;
disabled?: boolean;
};
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') ref(value);
else if (ref && typeof ref === 'object') (ref as { current: T | null }).current = value;
}
function TooltipTrigger({ content, shortcut, side = 'top', delay = 400, children, group }: TooltipProps & { group: GroupApi }) {
const id = useId();
const elRef = useRef<HTMLElement | null>(null);
const reqRef = useRef<Omit<TipRequest, 'el'>>({ id, content, shortcut, side });
reqRef.current = { id, content, shortcut, side };
const groupRef = useRef(group);
groupRef.current = group;
/** Touch has no hover: press and hold shows the tip, and the click that ends the hold is swallowed. */
const hold = useRef({ timer: 0, shown: false });
const endHold = () => {
window.clearTimeout(hold.current.timer);
if (hold.current.shown) hold.current.timer = window.setTimeout(() => groupRef.current.hide(id, false), 1500);
};
// Leaving the page (or this trigger unmounting) must not strand the tip.
useEffect(() => () => {
window.clearTimeout(hold.current.timer);
groupRef.current.hide(id, false);
}, [id]);
if (!isValidElement<TriggerProps>(children)) return <>{children}</>;
const own = children.props;
const childRef = (children as unknown as { props: { ref?: Ref<HTMLElement> } }).props.ref;
const request = (immediate: boolean) => {
const el = elRef.current;
if (!el || own.disabled) return;
group.show({ ...reqRef.current, el }, delay, immediate);
};
const active = group.activeId === id;
const describedBy = [own['aria-describedby'], active ? group.tipId : undefined].filter(Boolean).join(' ') || undefined;
return cloneElement(children, {
ref: (node: HTMLElement | null) => {
elRef.current = node;
setRef(childRef, node);
},
'aria-describedby': describedBy,
onPointerEnter: (e: PointerEvent<HTMLElement>) => {
own.onPointerEnter?.(e);
if (e.pointerType !== 'touch') request(false);
},
onPointerLeave: (e: PointerEvent<HTMLElement>) => {
own.onPointerLeave?.(e);
if (e.pointerType !== 'touch') group.hide(id, true);
},
onPointerDown: (e: PointerEvent<HTMLElement>) => {
own.onPointerDown?.(e);
// Never on a touch tap; only on a held press.
if (e.pointerType !== 'touch') return;
group.hide(id, false);
window.clearTimeout(hold.current.timer);
hold.current.shown = false;
hold.current.timer = window.setTimeout(() => {
hold.current.shown = true;
request(true);
}, 450);
},
onPointerUp: (e: PointerEvent<HTMLElement>) => {
own.onPointerUp?.(e);
if (e.pointerType === 'touch') endHold();
},
onPointerCancel: (e: PointerEvent<HTMLElement>) => {
own.onPointerCancel?.(e);
if (e.pointerType === 'touch') endHold();
},
onContextMenu: (e: MouseEvent<HTMLElement>) => {
if (hold.current.shown) e.preventDefault();
own.onContextMenu?.(e);
},
onClick: (e: MouseEvent<HTMLElement>) => {
if (hold.current.shown) {
hold.current.shown = false;
e.preventDefault();
return;
}
own.onClick?.(e);
},
onFocus: (e: FocusEvent<HTMLElement>) => {
own.onFocus?.(e);
// Keyboard focus only; a tap or click focuses without :focus-visible.
let keyboard = false;
try {
keyboard = isFocusVisible(e.currentTarget);
} catch {
keyboard = false;
}
if (keyboard) request(true);
},
onBlur: (e: FocusEvent<HTMLElement>) => {
own.onBlur?.(e);
group.hide(id, false);
},
});
}
/** A tip for one trigger. Inside a TooltipGroup it shares the group's warm state and its one tip. */
export function Tooltip(props: TooltipProps) {
const group = useContext(GroupContext);
if (group) return <TooltipTrigger {...props} group={group} />;
return (
<TooltipGroup accent={props.accent}>
<Tooltip {...props} />
</TooltipGroup>
);
}
export default Tooltip;
Customize
012345678900123456789001234567890400
Shows on keyboard focus, never on a touch tap.
Install
npx shadcn@latest add https://obsydeui.com/r/tooltip.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Tooltip } from '@/components/obsyde/ember/tooltip/Tooltip';
export function Example() {
return (
<Tooltip side="top" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/tooltip/Tooltip.tsxcomponentember/tooltip/tooltip-demo.cssstylesember/tooltip/tooltip.cssstylesember/tooltip/TooltipDemo.tsxdemoember/overlays-kit/demo.csssharedember/overlays-kit/kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
sideSide | 'top' | 'bottom' | 'left' | 'right' | 'top' |
shortcutsShortcuts | boolean | true |
delayDelay (ms) | number | 400 |