Tooltip
Placement is measured rather than assumed: the preferred side is tried first, and if the tooltip would overflow it flips to whichever side genuinely has more room and shifts along the cross axis to stay on screen. A tooltip rendering half off the edge is worse than none, and it happens to every fixed-placement implementation as soon as the trigger is near a boundary. It is a describedby relationship, not a label — using aria-label would REPLACE the trigger's visible name, so a voice-control user could no longer say what they can see.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Tooltip } from "@/components/tooltip"; <Tooltip />Customise
Focus always opens instantly — a keyboard user should not wait.
The component
components/craft/interface/Tooltip.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useId, useRef, useState } from "react"; /** * A tooltip that flips when it would leave the viewport. * * Placement is measured, not assumed. The preferred side is tried first, and * if the tooltip would overflow the viewport it flips to the opposite side and * shifts along the cross axis to stay on screen. A tooltip that renders * half-off the edge is worse than none, and it happens to every fixed-position * implementation the moment the trigger is near a boundary. * * It is a describedby relationship, not a label: the trigger keeps its own * accessible name and the tooltip adds to it. Using aria-label instead would * REPLACE the visible text, so a voice-control user could no longer say what * they can see. * * Opens on focus as well as hover, and Escape closes it — a keyboard user * reaches a tooltip by tabbing, and must be able to dismiss it without moving * focus away. */ type Side = "top" | "bottom" | "left" | "right"; export interface TooltipProps { children: React.ReactNode; content: string; side?: Side; /** Gap between trigger and tooltip, in pixels. */ offset?: number; /** Milliseconds before it appears on hover. Focus always opens instantly. */ delay?: number; still?: boolean; className?: string;} export function Tooltip({ children, content, side = "top", offset = 8, delay = 120, still = false, className = "",}: TooltipProps) { const id = useId(); const hostRef = useRef<HTMLSpanElement>(null); const bubbleRef = useRef<HTMLSpanElement>(null); const timer = useRef<number | undefined>(undefined); const [open, setOpen] = useState(false); const [placement, setPlacement] = useState<{ side: Side; shift: number }>({ side, shift: 0, }); /** Flip if the preferred side overflows; shift to stay within the edges. */ const place = () => { const host = hostRef.current; const bubble = bubbleRef.current; if (!host || !bubble) return; const trigger = host.getBoundingClientRect(); const box = bubble.getBoundingClientRect(); const margin = 8; let next: Side = side; const room = { top: trigger.top, bottom: window.innerHeight - trigger.bottom, left: trigger.left, right: window.innerWidth - trigger.right, }; const needed = side === "top" || side === "bottom" ? box.height : box.width; // Flip only if the opposite side genuinely has more room — flipping into // an equally cramped side just moves the problem. if (room[side] < needed + offset + margin) { const opposite: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left", }; if (room[opposite[side]] > room[side]) next = opposite[side]; } let shift = 0; if (next === "top" || next === "bottom") { const centre = trigger.left + trigger.width / 2; const half = box.width / 2; if (centre - half < margin) shift = margin - (centre - half); else if (centre + half > window.innerWidth - margin) { shift = window.innerWidth - margin - (centre + half); } } setPlacement({ side: next, shift }); }; const show = (immediate: boolean) => { window.clearTimeout(timer.current); const run = () => { setOpen(true); // Measured after it exists — a hidden element has no useful box. window.setTimeout(place, 0); }; if (immediate) run(); else timer.current = window.setTimeout(run, delay); }; const hide = () => { window.clearTimeout(timer.current); setOpen(false); }; const position: React.CSSProperties = { top: { bottom: "100%", left: "50%", marginBottom: offset }, bottom: { top: "100%", left: "50%", marginTop: offset }, left: { right: "100%", top: "50%", marginRight: offset }, right: { left: "100%", top: "50%", marginLeft: offset }, }[placement.side]; const translate = placement.side === "top" || placement.side === "bottom" ? `translateX(calc(-50% + ${placement.shift}px))` : "translateY(-50%)"; return ( <span ref={hostRef} className={`relative inline-block ${className}`} onPointerEnter={(e) => { // Touch already fires a tap; a hover delay there just feels broken. if (e.pointerType !== "touch") show(false); }} onPointerLeave={hide} onFocusCapture={() => show(true)} onBlurCapture={hide} onKeyDown={(e) => { if (e.key === "Escape") hide(); }} > {/* describedby, not label: the trigger keeps its own accessible name. */} <span aria-describedby={open ? id : undefined}>{children}</span> <span ref={bubbleRef} id={id} role="tooltip" // Always rendered so it can be measured; visibility is what changes. className="pointer-events-none absolute z-50 w-max max-w-[16rem] rounded-md border border-ink-500 bg-ink-800 px-2.5 py-1.5 font-mono text-micro leading-relaxed text-bone-200 shadow-xl" style={{ ...position, transform: translate, opacity: open ? 1 : 0, visibility: open ? "visible" : "hidden", transition: still ? "none" : "opacity 140ms linear", }} > {content} </span> </span> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+2.3KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
Appears and disappears with no fade. Nothing about a tooltip depends on the transition.
- Accessibility
- role=tooltip with aria-describedby, so the trigger keeps its own accessible name.
- Opens on focus as well as hover, and Escape dismisses it without moving focus.
- Hover delay is skipped for touch, where a tap has already happened.
- pointer-events-none, so it can never sit between the cursor and the trigger.
- Watch out
- Positioned within its own stacking context rather than portalled. An ancestor with overflow:hidden will clip it — portal it yourself if that is your layout.
- Measures on open, not on scroll. A tooltip left open while the page scrolls will not re-flip.