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.

PropDefaultTypeDescription

content

*
-

React.ReactNode

The content of the tooltip.

open

-

boolean

Used to control the tooltip's open state.

onOpenChange

-

(open: boolean, event?: Event, reason?: OpenChangeReason) => void

Callback function called when the tooltip's open state changes.

initialOpen

false

boolean

Whether the tooltip is open by default. Useful when used uncontrolled.

placement

"top"

Placement

The placement of the tooltip relative to the trigger.

offset

4

number

The distance between the tooltip and the trigger.

delayIn

600

number

The delay in milliseconds before the tooltip is shown.

delayOut

0

number

The delay in milliseconds before the tooltip is hidden.

disabled

false

boolean

Whether the tooltip is shown.

persistOnClick

false

boolean

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

  1. 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
  2. Timing:

    • Use appropriate delays to prevent accidental triggers
    • Adjust delays based on user behavior patterns
  3. Positioning:

    • Choose sensible default placements
    • Ensure tooltips don't obscure important content
    • Test behavior with different screen sizes
  4. Performance:

    • Avoid heavy content in tooltips