Component
Tooltip
The canonical compound tooltip: a real focusable trigger (aria-describedby), a role=tooltip bubble with a pointing arrow, hover-open after a short delay, immediate keyboard-focus open, and Escape/blur/pointer-leave dismissal.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/tooltips/tooltip/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Tooltips/tooltip React/Components/Tooltips/tooltip import {
Children,
cloneElement,
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
FocusEvent as ReactFocusEvent,
HTMLAttributes,
PointerEvent as ReactPointerEvent,
ReactElement,
ReactNode,
Ref,
RefObject,
} from "react";
/**
* DevSnips React Tooltip — reference implementation.
*
* A compound, non-interactive tooltip following the WAI-ARIA tooltip
* pattern: the trigger is a real focusable element (`aria-describedby`
* pointing at the tooltip), the tooltip is `role="tooltip"` content that
* opens on pointer hover (after `delayDuration`) AND on keyboard focus
* (immediately), and dismisses on pointer leave, blur, or Escape.
*
* Composition: `<Tooltip>` (root state + placement config) +
* `<TooltipTrigger>` (clones its single child element to attach the
* trigger ref, event handlers, and `aria-describedby`) +
* `<TooltipContent>` (the `role="tooltip"` bubble with a pointing arrow).
* Placement is prop-driven (`side` × `align`), measured against the
* viewport before paint, and flipped/shifted when the preferred placement
* would overflow — no positioning dependency.
*
* A tooltip is supplementary: it never carries interactive content and is
* never the only way to reach information.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type TooltipSide = "top" | "right" | "bottom" | "left";
export type TooltipAlign = "start" | "center" | "end";
export type TooltipPlacement = `${TooltipSide}-${TooltipAlign}`;
// `w-max` sizes the bubble to its content: the containing block is the
// (often tiny) trigger wrapper, so shrink-to-fit would squeeze the text to
// the trigger's width. max-w caps it at a readable measure / the viewport.
const CONTENT_CLASSES =
"pointer-events-none absolute z-40 w-max max-w-[min(16rem,calc(100vw-2rem))] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] px-2.5 py-1.5 text-left text-[13px] leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-sm)] transition-opacity duration-150 ease-out motion-reduce:transition-none";
const ARROW_BASE_CLASSES =
"absolute size-1.5 rotate-45 border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)]";
const POSITION_CLASSES: Record<TooltipPlacement, string> = {
"top-start": "bottom-full left-0",
"top-center": "bottom-full left-1/2 -translate-x-1/2",
"top-end": "bottom-full right-0",
"bottom-start": "top-full left-0",
"bottom-center": "top-full left-1/2 -translate-x-1/2",
"bottom-end": "top-full right-0",
"left-start": "right-full top-0",
"left-center": "right-full top-1/2 -translate-y-1/2",
"left-end": "right-full bottom-0",
"right-start": "left-full top-0",
"right-center": "left-full top-1/2 -translate-y-1/2",
"right-end": "left-full bottom-0",
};
// The two borders adjacent to the rotated square's trigger-facing corner,
// so the arrow reads as a notch pointing at the trigger.
const ARROW_BORDER_CLASSES: Record<TooltipSide, string> = {
top: "border-r border-b",
bottom: "border-l border-t",
left: "border-t border-r",
right: "border-b border-l",
};
const ARROW_POSITION_CLASSES: Record<TooltipPlacement, string> = {
"top-start": "left-3 top-full -translate-y-1/2",
"top-center": "left-1/2 top-full -translate-x-1/2 -translate-y-1/2",
"top-end": "right-3 top-full -translate-y-1/2",
"bottom-start": "bottom-full left-3 translate-y-1/2",
"bottom-center": "bottom-full left-1/2 -translate-x-1/2 translate-y-1/2",
"bottom-end": "bottom-full right-3 translate-y-1/2",
"left-start": "right-full top-2 translate-x-1/2",
"left-center": "right-full top-1/2 translate-x-1/2 -translate-y-1/2",
"left-end": "bottom-2 right-full translate-x-1/2",
"right-start": "left-full top-2 -translate-x-1/2",
"right-center": "left-full top-1/2 -translate-x-1/2 -translate-y-1/2",
"right-end": "bottom-2 left-full -translate-x-1/2",
};
function composeRefs<T>(...refs: Array<Ref<T> | undefined>): (node: T | null) => void {
return (node) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) (ref as { current: T | null }).current = node;
}
};
}
/* ------------------------------------------------------------------------ */
/* Root context */
/* ------------------------------------------------------------------------ */
interface TooltipContextValue {
open: boolean;
disabled: boolean;
handleTriggerPointerEnter(): void;
handleTriggerPointerLeave(): void;
handleTriggerFocus(): void;
handleTriggerBlur(): void;
triggerRef: RefObject<HTMLElement>;
contentId: string;
side: TooltipSide;
align: TooltipAlign;
sideOffset: number;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltip(component: string): TooltipContextValue {
const context = useContext(TooltipContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Tooltip>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Tooltip (root) */
/* ------------------------------------------------------------------------ */
export interface TooltipProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the tooltip requests to open or close. */
onOpenChange?: (open: boolean) => void;
/** Preferred side of the trigger; flips to stay in the viewport. */
side?: TooltipSide;
/** Alignment along the trigger; shifts to stay in the viewport. */
align?: TooltipAlign;
/** Gap between the trigger and the tooltip, in pixels. */
sideOffset?: number;
/** Hover delay before opening, in milliseconds (focus opens immediately). */
delayDuration?: number;
/** Suppress the tooltip entirely (hover and focus do nothing). */
disabled?: boolean;
className?: string;
children?: ReactNode;
}
export function Tooltip({
open,
defaultOpen = false,
onOpenChange,
side = "top",
align = "center",
sideOffset = 6,
delayDuration = 300,
disabled = false,
className,
children,
}: TooltipProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
// What opened the tooltip: pointer-opened tooltips close on pointer leave,
// focus-opened tooltips close on blur. This keeps the two gestures from
// fighting when both apply to the same trigger.
const [openedBy, setOpenedBy] = useState<"pointer" | "focus" | null>(actualOpen ? "focus" : null);
const triggerRef = useRef<HTMLElement>(null);
const hoveringRef = useRef(false);
const openTimerRef = useRef<number | null>(null);
const reactId = useId();
const contentId = `ds-tooltip-${reactId.replace(/:/g, "")}`;
function cancelScheduledOpen() {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}
function requestOpen(source: "pointer" | "focus") {
setOpenedBy(source);
if (!actualOpen) {
if (!isControlled) setInternalOpen(true);
onOpenChange?.(true);
}
}
function requestClose() {
cancelScheduledOpen();
setOpenedBy(null);
if (actualOpen) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
function handleTriggerPointerEnter() {
hoveringRef.current = true;
if (disabled || actualOpen) return;
cancelScheduledOpen();
openTimerRef.current = window.setTimeout(() => requestOpen("pointer"), delayDuration);
}
function handleTriggerPointerLeave() {
hoveringRef.current = false;
cancelScheduledOpen();
if (disabled) return;
if (openedBy === "pointer") requestClose();
}
function handleTriggerFocus() {
if (disabled) return;
cancelScheduledOpen();
// Focus opens without the hover delay: keyboard users must not wait.
requestOpen("focus");
}
function handleTriggerBlur() {
if (hoveringRef.current) {
// Focus left while the pointer still hovers: hand ownership to the
// pointer so the tooltip closes on pointer leave instead.
if (openedBy === "focus") setOpenedBy("pointer");
return;
}
requestClose();
}
// A tooltip that becomes disabled while open closes.
useEffect(() => {
if (disabled && actualOpen) requestClose();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled, actualOpen]);
// Escape dismisses the open tooltip; focus stays on the trigger.
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") requestClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen]);
// Never leave a scheduled hover-open behind on unmount.
useEffect(() => cancelScheduledOpen, []);
return (
<TooltipContext.Provider
value={{
open: actualOpen,
disabled,
handleTriggerPointerEnter,
handleTriggerPointerLeave,
handleTriggerFocus,
handleTriggerBlur,
triggerRef,
contentId,
side,
align,
sideOffset,
}}
>
<span className={cx("relative inline-flex", className)}>{children}</span>
</TooltipContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* TooltipTrigger */
/* ------------------------------------------------------------------------ */
interface TriggerChildProps {
ref?: Ref<HTMLElement>;
"aria-describedby"?: string;
onPointerEnter?: (event: ReactPointerEvent<HTMLElement>) => void;
onPointerLeave?: (event: ReactPointerEvent<HTMLElement>) => void;
onFocus?: (event: ReactFocusEvent<HTMLElement>) => void;
onBlur?: (event: ReactFocusEvent<HTMLElement>) => void;
}
export interface TooltipTriggerProps {
/**
* Exactly one element: a native focusable element (`<button>`, `<a>`,
* `<input>`, …) or a component that forwards its ref and these handlers.
* For a disabled control, wrap it in a `<span tabIndex={0}>` so the
* explanation stays reachable (see the tooltip-disabled-trigger variant).
*/
children: ReactElement;
}
export function TooltipTrigger({ children }: TooltipTriggerProps) {
const context = useTooltip("TooltipTrigger");
const child = Children.only(children) as ReactElement<TriggerChildProps>;
const childRef = (child as unknown as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
ref: composeRefs(context.triggerRef, childRef),
"aria-describedby": context.contentId,
onPointerEnter: (event: ReactPointerEvent<HTMLElement>) => {
child.props.onPointerEnter?.(event);
if (event.defaultPrevented) return;
context.handleTriggerPointerEnter();
},
onPointerLeave: (event: ReactPointerEvent<HTMLElement>) => {
child.props.onPointerLeave?.(event);
if (event.defaultPrevented) return;
context.handleTriggerPointerLeave();
},
onFocus: (event: ReactFocusEvent<HTMLElement>) => {
child.props.onFocus?.(event);
if (event.defaultPrevented) return;
context.handleTriggerFocus();
},
onBlur: (event: ReactFocusEvent<HTMLElement>) => {
child.props.onBlur?.(event);
if (event.defaultPrevented) return;
context.handleTriggerBlur();
},
});
}
/* ------------------------------------------------------------------------ */
/* TooltipContent */
/* ------------------------------------------------------------------------ */
export interface TooltipContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function TooltipContent({ className, children, ...rest }: TooltipContentProps) {
const context = useTooltip("TooltipContent");
const contentRef = useRef<HTMLDivElement>(null);
const [resolved, setResolved] = useState<{ side: TooltipSide; align: TooltipAlign }>({
side: context.side,
align: context.align,
});
const [measured, setMeasured] = useState(false);
const open = context.open;
// Measure against the viewport and flip the side / shift the alignment
// when the preferred placement would overflow. The first pass runs before
// paint (so the correction never flashes and doubles as the fade-in); a
// second pass runs one task later to refine against runtime-injected CSS
// (e.g. the Tailwind CDN inserts its rules in a MutationObserver microtask,
// after this layout effect — compiled CSS settles the second pass to a
// no-op).
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved({ side: context.side, align: context.align });
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
function measure() {
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const horizontal = context.side === "top" || context.side === "bottom";
let side = context.side;
if (horizontal) {
const below = window.innerHeight - t.bottom;
const above = t.top;
if (side === "top" && above < c.height + margin && below > above) side = "bottom";
else if (side === "bottom" && below < c.height + margin && above > below) side = "top";
} else {
const before = t.left;
const after = window.innerWidth - t.right;
if (side === "left" && before < c.width + margin && after > before) side = "right";
else if (side === "right" && after < c.width + margin && before > after) side = "left";
}
let align = context.align;
if (horizontal) {
const center = t.left + t.width / 2;
if (align === "center" && center - c.width / 2 < margin) align = "start";
else if (align === "center" && center + c.width / 2 > window.innerWidth - margin) align = "end";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
} else {
const center = t.top + t.height / 2;
if (align === "center" && center - c.height / 2 < margin) align = "start";
else if (align === "center" && center + c.height / 2 > window.innerHeight - margin) align = "end";
if (align === "start" && t.top + c.height > window.innerHeight - margin && t.bottom - c.height >= margin) {
align = "end";
} else if (align === "end" && t.bottom - c.height < margin && t.top + c.height <= window.innerHeight - margin) {
align = "start";
}
}
// sideOffset is applied here (not as a margin utility) so a numeric
// offset works for every side, including after a flip.
node.style.marginTop = side === "bottom" ? `${context.sideOffset}px` : "0px";
node.style.marginBottom = side === "top" ? `${context.sideOffset}px` : "0px";
node.style.marginLeft = side === "right" ? `${context.sideOffset}px` : "0px";
node.style.marginRight = side === "left" ? `${context.sideOffset}px` : "0px";
// Clamp the bubble to the room actually available on the resolved
// placement, so a trigger hard against the viewport edge keeps the
// tooltip on-screen (the text simply wraps taller). This only tightens
// the CSS max-w (the 16rem measure, or a className override) — reset
// the inline value first so the computed style reflects the CSS cap.
node.style.maxWidth = "";
const cssCap = parseFloat(getComputedStyle(node).maxWidth);
let available: number;
if (side === "right") available = window.innerWidth - t.right - context.sideOffset - margin;
else if (side === "left") available = t.left - context.sideOffset - margin;
else if (align === "start") available = window.innerWidth - t.left - margin;
else if (align === "end") available = t.right - margin;
else available = 2 * Math.min(t.left + t.width / 2, window.innerWidth - t.left - t.width / 2) - margin;
const cap = Math.min(available, window.innerWidth - 2 * margin, Number.isFinite(cssCap) ? cssCap : available);
node.style.maxWidth = `${Math.floor(Math.max(cap, 96))}px`;
setResolved({ side, align });
setMeasured(true);
}
measure();
const refine = window.setTimeout(measure, 0);
return () => window.clearTimeout(refine);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.side, context.align, context.sideOffset]);
if (!open) return null;
const placement: TooltipPlacement = `${resolved.side}-${resolved.align}`;
return (
<div
ref={contentRef}
id={context.contentId}
role="tooltip"
className={cx(
CONTENT_CLASSES,
POSITION_CLASSES[placement],
measured ? "opacity-100" : "opacity-0",
className,
)}
{...rest}
>
{children}
<span
aria-hidden="true"
className={cx(ARROW_BASE_CLASSES, ARROW_BORDER_CLASSES[resolved.side], ARROW_POSITION_CLASSES[placement])}
/>
</div>
);
}
export default Tooltip; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
Children,
cloneElement,
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const CONTENT_CLASSES = "pointer-events-none absolute z-40 w-max max-w-[min(16rem,calc(100vw-2rem))] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] px-2.5 py-1.5 text-left text-[13px] leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-sm)] transition-opacity duration-150 ease-out motion-reduce:transition-none";
const ARROW_BASE_CLASSES = "absolute size-1.5 rotate-45 border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)]";
const POSITION_CLASSES = {
"top-start": "bottom-full left-0",
"top-center": "bottom-full left-1/2 -translate-x-1/2",
"top-end": "bottom-full right-0",
"bottom-start": "top-full left-0",
"bottom-center": "top-full left-1/2 -translate-x-1/2",
"bottom-end": "top-full right-0",
"left-start": "right-full top-0",
"left-center": "right-full top-1/2 -translate-y-1/2",
"left-end": "right-full bottom-0",
"right-start": "left-full top-0",
"right-center": "left-full top-1/2 -translate-y-1/2",
"right-end": "left-full bottom-0"
};
const ARROW_BORDER_CLASSES = {
top: "border-r border-b",
bottom: "border-l border-t",
left: "border-t border-r",
right: "border-b border-l"
};
const ARROW_POSITION_CLASSES = {
"top-start": "left-3 top-full -translate-y-1/2",
"top-center": "left-1/2 top-full -translate-x-1/2 -translate-y-1/2",
"top-end": "right-3 top-full -translate-y-1/2",
"bottom-start": "bottom-full left-3 translate-y-1/2",
"bottom-center": "bottom-full left-1/2 -translate-x-1/2 translate-y-1/2",
"bottom-end": "bottom-full right-3 translate-y-1/2",
"left-start": "right-full top-2 translate-x-1/2",
"left-center": "right-full top-1/2 translate-x-1/2 -translate-y-1/2",
"left-end": "bottom-2 right-full translate-x-1/2",
"right-start": "left-full top-2 -translate-x-1/2",
"right-center": "left-full top-1/2 -translate-x-1/2 -translate-y-1/2",
"right-end": "bottom-2 left-full -translate-x-1/2"
};
function composeRefs(...refs) {
return (node) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
const TooltipContext = createContext(null);
function useTooltip(component) {
const context = useContext(TooltipContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Tooltip>.`);
}
return context;
}
function Tooltip({
open,
defaultOpen = false,
onOpenChange,
side = "top",
align = "center",
sideOffset = 6,
delayDuration = 300,
disabled = false,
className,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const [openedBy, setOpenedBy] = useState(actualOpen ? "focus" : null);
const triggerRef = useRef(null);
const hoveringRef = useRef(false);
const openTimerRef = useRef(null);
const reactId = useId();
const contentId = `ds-tooltip-${reactId.replace(/:/g, "")}`;
function cancelScheduledOpen() {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}
function requestOpen(source) {
setOpenedBy(source);
if (!actualOpen) {
if (!isControlled) setInternalOpen(true);
onOpenChange?.(true);
}
}
function requestClose() {
cancelScheduledOpen();
setOpenedBy(null);
if (actualOpen) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
function handleTriggerPointerEnter() {
hoveringRef.current = true;
if (disabled || actualOpen) return;
cancelScheduledOpen();
openTimerRef.current = window.setTimeout(() => requestOpen("pointer"), delayDuration);
}
function handleTriggerPointerLeave() {
hoveringRef.current = false;
cancelScheduledOpen();
if (disabled) return;
if (openedBy === "pointer") requestClose();
}
function handleTriggerFocus() {
if (disabled) return;
cancelScheduledOpen();
requestOpen("focus");
}
function handleTriggerBlur() {
if (hoveringRef.current) {
if (openedBy === "focus") setOpenedBy("pointer");
return;
}
requestClose();
}
useEffect(() => {
if (disabled && actualOpen) requestClose();
}, [disabled, actualOpen]);
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event) {
if (event.key === "Escape") requestClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [actualOpen]);
useEffect(() => cancelScheduledOpen, []);
return <TooltipContext.Provider
value={{
open: actualOpen,
disabled,
handleTriggerPointerEnter,
handleTriggerPointerLeave,
handleTriggerFocus,
handleTriggerBlur,
triggerRef,
contentId,
side,
align,
sideOffset
}}
>
<span className={cx("relative inline-flex", className)}>{children}</span>
</TooltipContext.Provider>;
}
function TooltipTrigger({ children }) {
const context = useTooltip("TooltipTrigger");
const child = Children.only(children);
const childRef = child.ref;
return cloneElement(child, {
ref: composeRefs(context.triggerRef, childRef),
"aria-describedby": context.contentId,
onPointerEnter: (event) => {
child.props.onPointerEnter?.(event);
if (event.defaultPrevented) return;
context.handleTriggerPointerEnter();
},
onPointerLeave: (event) => {
child.props.onPointerLeave?.(event);
if (event.defaultPrevented) return;
context.handleTriggerPointerLeave();
},
onFocus: (event) => {
child.props.onFocus?.(event);
if (event.defaultPrevented) return;
context.handleTriggerFocus();
},
onBlur: (event) => {
child.props.onBlur?.(event);
if (event.defaultPrevented) return;
context.handleTriggerBlur();
}
});
}
function TooltipContent({ className, children, ...rest }) {
const context = useTooltip("TooltipContent");
const contentRef = useRef(null);
const [resolved, setResolved] = useState({
side: context.side,
align: context.align
});
const [measured, setMeasured] = useState(false);
const open = context.open;
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved({ side: context.side, align: context.align });
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
function measure() {
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const horizontal = context.side === "top" || context.side === "bottom";
let side = context.side;
if (horizontal) {
const below = window.innerHeight - t.bottom;
const above = t.top;
if (side === "top" && above < c.height + margin && below > above) side = "bottom";
else if (side === "bottom" && below < c.height + margin && above > below) side = "top";
} else {
const before = t.left;
const after = window.innerWidth - t.right;
if (side === "left" && before < c.width + margin && after > before) side = "right";
else if (side === "right" && after < c.width + margin && before > after) side = "left";
}
let align = context.align;
if (horizontal) {
const center = t.left + t.width / 2;
if (align === "center" && center - c.width / 2 < margin) align = "start";
else if (align === "center" && center + c.width / 2 > window.innerWidth - margin) align = "end";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
} else {
const center = t.top + t.height / 2;
if (align === "center" && center - c.height / 2 < margin) align = "start";
else if (align === "center" && center + c.height / 2 > window.innerHeight - margin) align = "end";
if (align === "start" && t.top + c.height > window.innerHeight - margin && t.bottom - c.height >= margin) {
align = "end";
} else if (align === "end" && t.bottom - c.height < margin && t.top + c.height <= window.innerHeight - margin) {
align = "start";
}
}
node.style.marginTop = side === "bottom" ? `${context.sideOffset}px` : "0px";
node.style.marginBottom = side === "top" ? `${context.sideOffset}px` : "0px";
node.style.marginLeft = side === "right" ? `${context.sideOffset}px` : "0px";
node.style.marginRight = side === "left" ? `${context.sideOffset}px` : "0px";
node.style.maxWidth = "";
const cssCap = parseFloat(getComputedStyle(node).maxWidth);
let available;
if (side === "right") available = window.innerWidth - t.right - context.sideOffset - margin;
else if (side === "left") available = t.left - context.sideOffset - margin;
else if (align === "start") available = window.innerWidth - t.left - margin;
else if (align === "end") available = t.right - margin;
else available = 2 * Math.min(t.left + t.width / 2, window.innerWidth - t.left - t.width / 2) - margin;
const cap = Math.min(available, window.innerWidth - 2 * margin, Number.isFinite(cssCap) ? cssCap : available);
node.style.maxWidth = `${Math.floor(Math.max(cap, 96))}px`;
setResolved({ side, align });
setMeasured(true);
}
measure();
const refine = window.setTimeout(measure, 0);
return () => window.clearTimeout(refine);
}, [open, context.side, context.align, context.sideOffset]);
if (!open) return null;
const placement = `${resolved.side}-${resolved.align}`;
return <div
ref={contentRef}
id={context.contentId}
role="tooltip"
className={cx(
CONTENT_CLASSES,
POSITION_CLASSES[placement],
measured ? "opacity-100" : "opacity-0",
className
)}
{...rest}
>
{children}
<span
aria-hidden="true"
className={cx(ARROW_BASE_CLASSES, ARROW_BORDER_CLASSES[resolved.side], ARROW_POSITION_CLASSES[placement])}
/>
</div>;
}
export { Tooltip, TooltipTrigger, TooltipContent };
export default Tooltip; # Tooltip
The canonical compound tooltip: a real focusable trigger (aria-describedby), a role=tooltip bubble with a pointing arrow, hover-open after a short delay, immediate keyboard-focus open, and Escape/blur/pointer-leave dismissal.
## Usage
```tsx
import Tooltip, { TooltipTrigger, TooltipContent } from "./tooltip";
<Tooltip>
<TooltipTrigger>
<button type="button" aria-label="About the retention period">
<InfoIcon />
</button>
</TooltipTrigger>
<TooltipContent>
How long deleted projects stay recoverable.
</TooltipContent>
</Tooltip>
// Placement is a prop, not a variant:
<Tooltip side="right" align="start" sideOffset={8}>…</Tooltip>
// Uncontrolled (default) or controlled:
const [open, setOpen] = useState(false);
<Tooltip open={open} onOpenChange={setOpen}>…</Tooltip>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Tooltip, { TooltipTrigger, TooltipContent } from "./tooltip";
<Tooltip>
<TooltipTrigger>
<button type="button" aria-label="About the retention period">
<InfoIcon />
</button>
</TooltipTrigger>
<TooltipContent>
How long deleted projects stay recoverable.
</TooltipContent>
</Tooltip>
// Placement is a prop, not a variant:
<Tooltip side="right" align="start" sideOffset={8}>…</Tooltip>
// Uncontrolled (default) or controlled:
const [open, setOpen] = useState(false);
<Tooltip open={open} onOpenChange={setOpen}>…</Tooltip>
```
## Props
### `<Tooltip>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the tooltip requests to open or close (hover, focus, blur, Escape). |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"top"` | Preferred side of the trigger; flips to the opposite side when it would overflow the viewport. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along the trigger; shifts toward the edge with room when it would overflow. |
| `sideOffset` | `number` | `6` | Gap between the trigger and the tooltip, in pixels. Applied after any flip. |
| `delayDuration` | `number` | `300` | Hover delay before opening, in milliseconds. Keyboard focus always opens immediately. |
| `disabled` | `boolean` | `false` | Suppress the tooltip entirely: hover and focus do nothing, and a tooltip that becomes disabled while open closes. |
| `className` | `string` | — | Extra classes on the positioning wrapper (a `relative inline-flex` span). |
| `children` | `ReactNode` | — | A single `TooltipTrigger` + a single `TooltipContent`. |
### `<TooltipTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `children` | `ReactElement` | — | Exactly one element: a native focusable element (`<button>`, `<a>`, `<input>`, …) or a component that forwards its ref and the pointer/focus handlers. |
`TooltipTrigger` clones the child to attach the trigger ref, the hover/focus handlers, and `aria-describedby` pointing at the tooltip. The child's own handlers run first and can cancel the tooltip behavior with `event.preventDefault()`. The trigger must be focusable — a tooltip must never depend on hover alone. For a natively `disabled` control (which cannot receive hover or focus), wrap it in a `<span tabIndex={0}>` so the explanation stays reachable — see `tooltip-disabled-trigger`.
### `<TooltipContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="tooltip"` bubble (e.g. a larger `max-w-*`). |
| `children` | `ReactNode` | — | Text or structured, **non-interactive** content. |
`role="tooltip"` is fixed; every native `div` attribute (`aria-*`, `data-*`, …) is forwarded via `...rest`. Rendered only while open, `pointer-events-none`, capped at `min(16rem, 100vw - 2rem)` wide. Content that must be clicked or focused does not belong in a tooltip — use a popover or a dialog instead.
## Composition
- `Tooltip` — the root provider. Owns the open state (controlled `open` + `onOpenChange`, or uncontrolled `defaultOpen`), the placement config (`side`, `align`, `sideOffset`), the hover `delayDuration`, the `disabled` switch, and the generated tooltip id. Renders a `relative inline-flex` wrapper the content is positioned against.
- `TooltipTrigger` — clones its single child element (a real focusable element, or a `<span tabIndex={0}>` around a disabled control) to attach the trigger ref, the pointer/focus handlers, and `aria-describedby` pointing at the tooltip.
- `TooltipContent` — the `role="tooltip"` bubble plus its pointing arrow. Rendered only while open, `pointer-events-none` (a tooltip never carries interactive content), measured against the viewport before paint and flipped/shifted when the preferred placement would overflow.
This is the reference composition — every other variant in the family uses the same three primitives and extends the same class constants, placement model, and accessibility model. Direction is never a separate component: `side` and `align` are props on the root.
## Tooltip Behavior
The root `<Tooltip>` owns the open state. Both modes are supported:
- **Controlled** — pass `open` + `onOpenChange`; the parent owns the state.
- **Uncontrolled** — pass `defaultOpen`; the component owns the state.
Hover opens the tooltip after `delayDuration` (default 300 ms, so passing cursors do not flash it); keyboard focus opens it **immediately** — keyboard users never wait for a hover delay. The component tracks *what* opened it: a pointer-opened tooltip closes on pointer leave, a focus-opened tooltip closes on blur. If focus leaves while the pointer still hovers, ownership hands over to the pointer so the tooltip closes on pointer leave instead — the two gestures never fight.
Escape dismisses the open tooltip (focus stays on the trigger). The `disabled` prop suppresses opening entirely — and a tooltip that becomes disabled while open closes. A pending hover-open timer is cancelled on pointer leave and on unmount, so a tooltip never opens after its trigger is gone.
On touch devices there is no hover: tapping the trigger fires the same pointer path, so the tooltip appears on tap and dismisses on the next outside interaction. Touch users get the same content as pointer users.
## Positioning
Placement is prop-driven — `side` (`top` / `right` / `bottom` / `left`) × `align` (`start` / `center` / `end`), with `sideOffset` (pixels, default 6) for the trigger gap. `center` aligns the tooltip's center with the trigger's center; `start` / `end` align the leading / trailing edges.
Before paint, `TooltipContent` measures itself and the trigger against the viewport (an 8px margin) and corrects the placement when it would overflow: `top` ↔ `bottom` and `left` ↔ `right` flip when the preferred side lacks room and the opposite side has more, and an `align` that would overflow an edge shifts toward the side with room (`center` degrades to `start` / `end` first). The correction runs in a layout effect while the content is still transparent, so the flip never flashes. `sideOffset` is applied after the flip, so the gap always points the right way.
The content is absolutely positioned inside the root's `relative inline-flex` wrapper — there is no portal and no positioning library. The bubble is capped at `min(16rem, 100vw - 2rem)` wide, so even long content stays inside a 375px viewport. One honest constraint of the no-portal approach: an ancestor with `overflow: hidden` (and a stacking trap) can clip the bubble — place the `<Tooltip>` outside clipping containers.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Tab` | Moves focus to the trigger; a focused trigger opens its tooltip immediately (no hover delay) |
| `Shift+Tab` / `Tab` away | Blur dismisses the tooltip |
| `Escape` | Dismiss the open tooltip; focus stays on the trigger |
The trigger is a real focusable element (a `<button>`, `<a>`, or — for a disabled control — a `<span tabIndex={0}>`), so Enter/Space activation and tab order follow normal browser behavior. The tooltip itself is not focusable and contains no interactive elements — it is announced through the trigger's `aria-describedby`.
## Accessibility
The structure follows the WAI-ARIA tooltip pattern.
- The trigger is a real focusable element — a tooltip must never depend on hover alone. Keyboard focus opens the tooltip exactly like pointer hover.
- The tooltip is `role="tooltip"`, and the trigger carries `aria-describedby` pointing at the tooltip's id, so assistive technology announces the tooltip text as the trigger's description when it appears.
- The tooltip is `pointer-events-none` and never contains interactive content (links, buttons, inputs). Content that must be interacted with belongs in a popover or dialog, not a tooltip.
- A tooltip is **supplementary**: it must never be the only way to reach essential information. Everything it says is either repeated in the visible UI or genuinely optional detail.
- A natively `disabled` control does not receive hover or focus events, so a tooltip explaining *why* it is disabled must wrap it in a focusable `<span tabIndex={0}>` (the inner control gets `pointer-events-none`) — see `tooltip-disabled-trigger`.
The info trigger is a real `<button type="button">` with its own `aria-label`, so it is reachable by keyboard and announced correctly even before the tooltip opens; the tooltip then supplies the longer description through `aria-describedby`. Mounting several `<Tooltip>` roots on one page is safe: each root scopes its own id, timer, and listeners — hover or focus on one trigger dismisses the others naturally (through pointer leave / blur), and Escape dismisses whichever is open.
## States
- **Trigger** — the wrapped element keeps its own styling and its visible `:focus-visible` ring (`--ds-color-focus-ring`); the tooltip adds no visual state to the trigger.
- **Content** — `surface-elevated` with a 1px `--ds-color-border` and a restrained `--ds-shadow-sm`, radius-md, 13px/20px text, per the Dropdown / Popover / Tooltip token rules.
- **Arrow** — a rotated square sharing the content's surface and border, notched toward the trigger; follows the resolved placement (including after a flip).
- **Open transition** — a subtle 150ms opacity fade-in that doubles as the pre-measurement guard; `motion-reduce:transition-none` disables it.
- **Disabled** — the `disabled` prop suppresses opening; a natively `disabled` trigger cannot receive hover/focus, so the tooltip pattern for disabled controls is the focusable `<span tabIndex={0}>` wrapper (see `tooltip-disabled-trigger`).
## Responsive Behavior
The bubble is capped at `min(16rem, 100vw - 2rem)` wide, wraps its text, and is measured against the viewport before paint — flipping sides or shifting alignment when it would overflow. The trigger keeps its own size (36px controls in the demos — a comfortable touch target). On touch devices the tooltip appears on tap, since there is no hover. Every demo is verified overflow-free at 375 / 768 / 1280px with the tooltip open and closed.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This tooltip variant uses the semantic color, radius, shadow, typography, and motion tokens — per the Dropdown / Popover / Tooltip row of the component token rules (radius-md, shadow-sm–md, 1px subtle border, body-sm text).
## Notes
Reference implementation for the Tooltips family. It establishes the shared tooltip geometry (radius-md bubble, 10px/6px padding rhythm, 16rem measure, 6px default offset), the arrow treatment, the hover-delay vs immediate-focus-open model, the pointer/focus ownership handoff, and the measure-then-flip placement that every other variant reuses. 465 lines UTF-8 · LF · Spaces: 2
Continue browsing