Tooltip
Tooltips are used to display information on demand.
Dependencies
Source Code
"use client";
import {
cloneElement,
isValidElement,
useCallback,
useRef,
useState,
} from "react";
import {
autoUpdate,
flip,
offset as offsetMiddleware,
FloatingDelayGroup,
Placement,
useFloating,
UseFloatingOptions,
arrow,
useDelayGroup,
useHover,
safePolygon,
useFocus,
useDismiss,
useRole,
useInteractions,
useMergeRefs,
FloatingArrow,
FloatingPortal,
useTransitionStatus,
hide,
} from "@floating-ui/react";
import { cn } from "@/lib/utils";
const DEFAULT_DELAY_IN = 600;
const DEFAULT_DELAY_OUT = 0;
const ARROW_HEIGHT = 4;
const ARROW_WIDTH = 8;
const DEFAULT_GROUP_TIMEOUT_MS = 150;
interface TooltipProps
extends Omit<React.ComponentPropsWithRef<"div">, "content"> {
initialOpen?: boolean;
open?: boolean;
onOpenChange?: UseFloatingOptions["onOpenChange"];
placement?: Placement;
offset?: number;
delayIn?: number;
delayOut?: number;
disabled?: boolean;
persistOnClick?: boolean;
content: React.ReactNode;
children: React.ReactNode;
}
/**
* Tooltip component
*
* @example
* ```
* <Tooltip content="Tooltip content">
* <Button>Hover me</Button>
* </Tooltip>
* ```
*/
const Tooltip = ({
ref,
content,
children,
className,
initialOpen = false,
open: propsOpen,
onOpenChange: propsOnOpenChange,
placement = "top",
offset = 4,
delayIn = DEFAULT_DELAY_IN,
delayOut = DEFAULT_DELAY_OUT,
disabled = false,
persistOnClick = false,
...props
}: TooltipProps) => {
const arrowRef = useRef<SVGSVGElement | null>(null);
const [internalOpen, setInternalOpen] = useState(initialOpen);
const open = propsOpen ?? internalOpen;
const setOpen = useCallback<NonNullable<UseFloatingOptions["onOpenChange"]>>(
(open, event, reason) => {
setInternalOpen(open);
propsOnOpenChange?.(open, event, reason);
},
[propsOnOpenChange]
);
const floating = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
flip({ fallbackAxisSideDirection: "start", padding: offset * 2 }),
offsetMiddleware(offset + ARROW_HEIGHT),
arrow({ element: arrowRef, padding: 8 }),
hide(),
],
});
const ctx = floating.context;
const { delay: groupDelay } = useDelayGroup(ctx);
const hover = useHover(ctx, {
enabled: !disabled,
move: false,
delay: {
open: typeof groupDelay === "object" ? groupDelay.open : delayIn,
close: typeof groupDelay === "object" ? groupDelay.close : delayOut,
},
handleClose: safePolygon({}),
});
const focus = useFocus(ctx, {
enabled: !disabled,
});
const dismiss = useDismiss(ctx, {
referencePress: !persistOnClick,
});
const role = useRole(ctx, { role: "tooltip" });
const interactions = useInteractions([hover, focus, dismiss, role]);
const contentRef = useMergeRefs([ctx.refs.setFloating, ref]);
const { isMounted, status } = useTransitionStatus(ctx, {
duration: 0,
});
return (
<>
{isValidElement(children) ? (
cloneElement(
children,
interactions.getReferenceProps({ ref: ctx.refs.setReference })
)
) : (
<span
ref={ctx.refs.setReference}
{...interactions.getReferenceProps(props)}
className="inline-block outline-none"
>
{children}
</span>
)}
{isMounted && (
<FloatingPortal>
<div
ref={contentRef}
className={cn(
"bg-foreground text-background ease-out-quint z-50 max-w-80 rounded-lg px-3 py-1.5 text-xs break-words drop-shadow-md transition duration-300",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=top]:translate-y-2",
"data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 data-[state=open]:scale-100",
floating.middlewareData.hide?.referenceHidden && "hidden",
className
)}
data-state={status === "open" ? "open" : "closed"}
data-side={ctx.placement.split("-")[0]}
style={{
position: ctx.strategy,
top: ctx.y ?? 0,
left: ctx.x ?? 0,
...props.style,
}}
{...interactions.getFloatingProps(props)}
>
<FloatingArrow
ref={arrowRef}
context={ctx}
className="fill-foreground"
tipRadius={1}
height={ARROW_HEIGHT}
width={ARROW_WIDTH}
/>
{content}
</div>
</FloatingPortal>
)}
</>
);
};
interface TooltipGroupProps {
children: React.ReactNode;
delayIn?: number;
delayOut?: number;
timeoutMs?: number;
}
/**
* TooltipGroup allows you to group tooltips so they won't have delay when you move between them.
*
* This is very useful for navigation or toolbars where you want the first tooltip to have significant delay but moving to the next item should be instant so the user can scan all options.
*
* @example
* ```
* <TooltipGroup>
* <div className="flex gap-2">
* {tools.map((tool) => (
* <Tooltip key={tool.id} content={tool.label}>
* <Button>{tool.icon}</Button>
* </Tooltip>
* ))}
* </div>
* </TooltipGroup>
* ```
*/
const TooltipGroup = ({
delayIn = DEFAULT_DELAY_IN,
delayOut = DEFAULT_DELAY_OUT,
timeoutMs = DEFAULT_GROUP_TIMEOUT_MS,
children,
}: TooltipGroupProps) => {
return (
<FloatingDelayGroup
delay={{ open: delayIn, close: delayOut }}
timeoutMs={timeoutMs}
>
{children}
</FloatingDelayGroup>
);
};
export { Tooltip, TooltipGroup };
Features
- Smart Positioning: Automatically adjusts position to stay in view
- Configurable Delays: Customizable show/hide delays to prevent flicker
- Group Support: Coordinated behavior when multiple tooltips are nearby
- Rich Content: Supports any React node as content
- Controlled Mode: Optional controlled state management
API Reference
Extends the div
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| The content of the tooltip. |
| - |
| Used to control the tooltip's open state. |
| - |
| Callback function called when the tooltip's open state changes. |
|
|
| Whether the tooltip is open by default. Useful when used uncontrolled. |
|
|
| The placement of the tooltip relative to the trigger. |
|
|
| The distance between the tooltip and the trigger. |
|
|
| The delay in milliseconds before the tooltip is shown. |
|
|
| The delay in milliseconds before the tooltip is hidden. |
|
|
| Whether the tooltip is shown. |
|
|
| Whether the tooltip persists when the trigger is clicked. |
Examples
Group
Tooltips placed within a TooltipGroup
won't have delay between them, creating a smooth experience when moving between multiple tooltips.
Placement
Controlling the position of the tooltip relative to its trigger.
Persist on Click
Keeping the tooltip visible when the trigger is clicked.
Long Content
Handling tooltips with longer text content.
Rich Content
Using complex content inside tooltips.
Automatic reposition
The tooltip automatically repositions to stay in view when scrolling.
Best Practices
-
Content Guidelines:
- Keep tooltip content concise and focused
- Use for supplementary information only
- Avoid using for essential information
- Consider using other components for complex interactions
-
Timing:
- Use appropriate delays to prevent accidental triggers
- Adjust delays based on user behavior patterns
-
Positioning:
- Choose sensible default placements
- Ensure tooltips don't obscure important content
- Test behavior with different screen sizes
-
Performance:
- Avoid heavy content in tooltips