Component
Tooltip with Icon Trigger
Icon-only toolbar buttons: every trigger carries a real aria-label (the accessible name), and the tooltip makes that name visible on hover or focus — the standard pattern for toolbars and action rows.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/tooltips/tooltip-with-icon/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Tooltips/tooltip-with-icon React/Components/Tooltips/tooltip-with-icon 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 — icon-trigger variant.
*
* Identical core to the reference tooltip; this variant demonstrates the
* icon-only trigger pattern: a real button whose accessible name comes
* from its own aria-label, with the tooltip rendering the same label on
* hover and keyboard focus.
*/
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 with Icon Trigger
Icon-only toolbar buttons: every trigger carries a real aria-label (the accessible name), and the tooltip makes that name visible on hover or focus — the standard pattern for toolbars and action rows.
## Usage
```tsx
import Tooltip, { TooltipTrigger, TooltipContent } from "./tooltip-with-icon";
<Tooltip>
<TooltipTrigger>
<button type="button" aria-label="Copy link">
<CopyIcon />
</button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</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-with-icon";
<Tooltip>
<TooltipTrigger>
<button type="button" aria-label="Copy link">
<CopyIcon />
</button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</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.
An icon-only trigger must name itself: the button carries the real `aria-label` (that is its accessible name — the SVG is `aria-hidden`), and the tooltip renders the same label visually. The tooltip complements the name; it never replaces it.
## 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`.
Without visible text, an icon button's only accessible name is its `aria-label` — so the label exists even where tooltips never appear (screen readers before interaction, touch devices, voice control). The tooltip then exposes the identical label to sighted pointer and keyboard users. Icon glyphs are `aria-hidden`; meaning comes from the label, not the graphic.
## 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
The toolbar row is the everyday use of this variant: five icon-only actions, each a 36px control with `aria-label` + matching tooltip. Keyboard users Tab through the row and get the same names as pointer users, with no hover delay. 453 lines UTF-8 · LF · Spaces: 2
Continue browsing