React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

tooltippopoverhoveraccessibilitycomponenttailwind

Tooltip

A small floating label that appears on hover or focus to provide supplementary information about an element without cluttering the interface.

Use Cases

  • Explain icon-only buttons (edit, delete, settings)
  • Show the full text of a truncated label or cell
  • Display keyboard shortcuts next to toolbar actions
  • Provide additional context for form fields or status indicators
  • Reveal timestamps or metadata on compact UI elements
  • Clarify disabled or restricted actions

Simplest Implementation

"use client";
 
import { useState } from "react";
 
interface TooltipProps {
  text: string;
  children: React.ReactNode;
}
 
export function Tooltip({ text, children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
    >
      {children}
      {visible && (
        <div className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white">
          {text}
        </div>
      )}
    </div>
  );
}

A minimal tooltip positioned above the trigger using bottom-full and centered with left-1/2 -translate-x-1/2. The whitespace-nowrap prevents the tooltip from wrapping to a new line for short text. Visibility is toggled by mouseEnter and mouseLeave events on the wrapper.

Variations

Top / Bottom / Left / Right Placement

"use client";
 
import { useState } from "react";
 
type Placement = "top" | "bottom" | "left" | "right";
 
interface TooltipProps {
  text: string;
  placement?: Placement;
  children: React.ReactNode;
}
 
const placementClasses: Record<Placement, string> = {
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
  bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  left: "right-full top-1/2 -translate-y-1/2 mr-2",
  right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
 
export function Tooltip({ text, placement = "top", children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
    >
      {children}
      {visible && (
        <div
          role="tooltip"
          className={`absolute z-50 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white ${placementClasses[placement]}`}
        >
          {text}
        </div>
      )}
    </div>
  );
}

Each placement maps to a different combination of positioning and transform classes. Horizontal placements (left/right) use -translate-y-1/2 for vertical centering, while vertical placements (top/bottom) use -translate-x-1/2 for horizontal centering.

With Arrow

"use client";
 
import { useState } from "react";
 
type Placement = "top" | "bottom" | "left" | "right";
 
interface TooltipProps {
  text: string;
  placement?: Placement;
  children: React.ReactNode;
}
 
const placementClasses: Record<Placement, string> = {
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
  bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
  left: "right-full top-1/2 -translate-y-1/2 mr-2",
  right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
 
const arrowClasses: Record<Placement, string> = {
  top: "top-full left-1/2 -translate-x-1/2 border-t-gray-900 border-x-transparent border-b-transparent border-4",
  bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-900 border-x-transparent border-t-transparent border-4",
  left: "left-full top-1/2 -translate-y-1/2 border-l-gray-900 border-y-transparent border-r-transparent border-4",
  right: "right-full top-1/2 -translate-y-1/2 border-r-gray-900 border-y-transparent border-l-transparent border-4",
};
 
export function Tooltip({ text, placement = "top", children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
    >
      {children}
      {visible && (
        <div
          role="tooltip"
          className={`absolute z-50 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white ${placementClasses[placement]}`}
        >
          {text}
          <div className={`absolute h-0 w-0 ${arrowClasses[placement]}`} />
        </div>
      )}
    </div>
  );
}

The arrow is a zero-width, zero-height div with CSS border tricks. Each placement gets a different combination of transparent and colored borders to point the arrow toward the trigger element. No extra SVG or image assets are needed.

Delayed Show

"use client";
 
import { useState, useRef } from "react";
 
interface TooltipProps {
  text: string;
  delay?: number;
  children: React.ReactNode;
}
 
export function Tooltip({ text, delay = 400, children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
  function handleMouseEnter() {
    timerRef.current = setTimeout(() => setVisible(true), delay);
  }
 
  function handleMouseLeave() {
    if (timerRef.current) clearTimeout(timerRef.current);
    setVisible(false);
  }
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
      {visible && (
        <div
          role="tooltip"
          className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white"
        >
          {text}
        </div>
      )}
    </div>
  );
}

A setTimeout delays the tooltip appearance so it does not flash when the user moves the cursor quickly across many trigger elements. The timer is stored in a useRef (not state) to avoid re-renders, and is cleared on mouseLeave to cancel the tooltip if the user moves away before the delay completes.

Rich Content Tooltip

"use client";
 
import { useState } from "react";
 
interface TooltipProps {
  content: React.ReactNode;
  children: React.ReactNode;
  maxWidth?: string;
}
 
export function Tooltip({ content, children, maxWidth = "16rem" }: TooltipProps) {
  const [visible, setVisible] = useState(false);
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
    >
      {children}
      {visible && (
        <div
          role="tooltip"
          style={{ maxWidth }}
          className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 rounded-lg bg-gray-900 px-3 py-2 text-sm text-white shadow-lg"
        >
          {content}
        </div>
      )}
    </div>
  );
}
 
// Usage:
// <Tooltip
//   content={
//     <div>
//       <p className="font-semibold">Pro plan</p>
//       <p className="mt-1 text-gray-300">Includes unlimited projects and priority support.</p>
//     </div>
//   }
// >
//   <span className="underline decoration-dotted cursor-help">Pro</span>
// </Tooltip>

Accepts ReactNode instead of a plain string, enabling headings, paragraphs, links, or even images inside the tooltip. The maxWidth prop (inline style) constrains the width so long content wraps naturally rather than stretching the tooltip across the screen.

On Focus (Accessible)

"use client";
 
import { useState, useId } from "react";
 
interface TooltipProps {
  text: string;
  children: React.ReactElement<React.HTMLAttributes<HTMLElement>>;
}
 
export function Tooltip({ text, children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const id = useId();
 
  return (
    <div
      className="relative inline-block"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
      onFocus={() => setVisible(true)}
      onBlur={() => setVisible(false)}
    >
      <div aria-describedby={visible ? id : undefined}>
        {children}
      </div>
      {visible && (
        <div
          id={id}
          role="tooltip"
          className="absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white"
        >
          {text}
        </div>
      )}
    </div>
  );
}

Adds onFocus/onBlur handlers alongside mouse events so keyboard-only users see the tooltip when they Tab to the trigger. The aria-describedby attribute links the trigger to the tooltip content, letting screen readers read the tooltip text as the element's description. The useId hook generates a unique ID to avoid conflicts when multiple tooltips exist on the same page.

Complex Implementation

"use client";
 
import {
  useState,
  useRef,
  useEffect,
  useCallback,
  useId,
  createContext,
  useContext,
} from "react";
import { createPortal } from "react-dom";
 
// --- Types ---
 
type Placement = "top" | "bottom" | "left" | "right";
 
interface TooltipContextValue {
  open: boolean;
  show: () => void;
  hide: () => void;
  placement: Placement;
  triggerRef: React.RefObject<HTMLDivElement | null>;
  tooltipId: string;
}
 
// --- Context ---
 
const TooltipContext = createContext<TooltipContextValue | null>(null);
 
function useTooltipContext() {
  const ctx = useContext(TooltipContext);
  if (!ctx) throw new Error("Tooltip compound components must be used inside <Tooltip>");
  return ctx;
}
 
// --- Root ---
 
interface TooltipProps {
  children: React.ReactNode;
  placement?: Placement;
  delay?: number;
  offset?: number;
}
 
export function Tooltip({ children, placement = "top", delay = 300, offset = 8 }: TooltipProps) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLDivElement>(null);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const tooltipId = useId();
 
  const show = useCallback(() => {
    timerRef.current = setTimeout(() => setOpen(true), delay);
  }, [delay]);
 
  const hide = useCallback(() => {
    if (timerRef.current) clearTimeout(timerRef.current);
    setOpen(false);
  }, []);
 
  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);
 
  return (
    <TooltipContext.Provider value={{ open, show, hide, placement, triggerRef, tooltipId }}>
      {children}
    </TooltipContext.Provider>
  );
}
 
// --- Trigger ---
 
export function TooltipTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
  const { show, hide, triggerRef, tooltipId, open } = useTooltipContext();
 
  return (
    <div
      ref={triggerRef}
      onMouseEnter={show}
      onMouseLeave={hide}
      onFocus={show}
      onBlur={hide}
      aria-describedby={open ? tooltipId : undefined}
      className={className ?? "inline-block"}
    >
      {children}
    </div>
  );
}
 
// --- Content ---
 
interface TooltipContentProps {
  children: React.ReactNode;
  className?: string;
}
 
const placementStyles: Record<Placement, (rect: DOMRect, offset: number) => { top: number; left: number }> = {
  top: (rect, offset) => ({
    top: rect.top + window.scrollY - offset,
    left: rect.left + window.scrollX + rect.width / 2,
  }),
  bottom: (rect, offset) => ({
    top: rect.bottom + window.scrollY + offset,
    left: rect.left + window.scrollX + rect.width / 2,
  }),
  left: (rect, offset) => ({
    top: rect.top + window.scrollY + rect.height / 2,
    left: rect.left + window.scrollX - offset,
  }),
  right: (rect, offset) => ({
    top: rect.top + window.scrollY + rect.height / 2,
    left: rect.right + window.scrollX + offset,
  }),
};
 
const placementTransform: Record<Placement, string> = {
  top: "-translate-x-1/2 -translate-y-full",
  bottom: "-translate-x-1/2",
  left: "-translate-x-full -translate-y-1/2",
  right: "-translate-y-1/2",
};
 
const arrowClasses: Record<Placement, string> = {
  top: "top-full left-1/2 -translate-x-1/2 border-t-gray-900 border-x-transparent border-b-transparent border-4",
  bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-900 border-x-transparent border-t-transparent border-4",
  left: "left-full top-1/2 -translate-y-1/2 border-l-gray-900 border-y-transparent border-r-transparent border-4",
  right: "right-full top-1/2 -translate-y-1/2 border-r-gray-900 border-y-transparent border-l-transparent border-4",
};
 
export function TooltipContent({ children, className }: TooltipContentProps) {
  const { open, placement, triggerRef, tooltipId } = useTooltipContext();
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const [mounted, setMounted] = useState(false);
  const offset = 8;
 
  useEffect(() => setMounted(true), []);
 
  useEffect(() => {
    if (!open || !triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    setCoords(placementStyles[placement](rect, offset));
  }, [open, placement, triggerRef]);
 
  // Reposition on scroll or resize
  useEffect(() => {
    if (!open) return;
 
    function reposition() {
      if (!triggerRef.current) return;
      const rect = triggerRef.current.getBoundingClientRect();
      setCoords(placementStyles[placement](rect, offset));
    }
 
    window.addEventListener("scroll", reposition, true);
    window.addEventListener("resize", reposition);
    return () => {
      window.removeEventListener("scroll", reposition, true);
      window.removeEventListener("resize", reposition);
    };
  }, [open, placement, triggerRef]);
 
  // Close on Escape
  useEffect(() => {
    if (!open) return;
 
    function handleKey(e: KeyboardEvent) {
      if (e.key === "Escape") {
        e.preventDefault();
        triggerRef.current?.blur();
      }
    }
 
    document.addEventListener("keydown", handleKey);
    return () => document.removeEventListener("keydown", handleKey);
  }, [open, triggerRef]);
 
  if (!open || !mounted) return null;
 
  return createPortal(
    <div
      id={tooltipId}
      role="tooltip"
      className={`fixed z-50 rounded-lg bg-gray-900 px-3 py-1.5 text-xs text-white shadow-lg ${placementTransform[placement]} ${className ?? ""}`}
      style={{ top: coords.top, left: coords.left }}
    >
      {children}
      <div className={`absolute h-0 w-0 ${arrowClasses[placement]}`} />
    </div>,
    document.body
  );
}

Key aspects:

  • Compound component pattern -- Tooltip, TooltipTrigger, and TooltipContent share state through context. This separates the trigger element from the tooltip content, allowing flexible composition.
  • Portal rendering -- createPortal renders the tooltip at document.body so it escapes overflow:hidden containers and avoids stacking context clipping. Position is calculated from the trigger's getBoundingClientRect.
  • Dynamic repositioning -- scroll and resize listeners recalculate the tooltip position while it is visible. The scroll listener uses { capture: true } to catch scroll events on any ancestor, not just the window.
  • Delayed show -- a configurable delay prop prevents tooltip flicker when the user moves the cursor quickly across multiple triggers. The timer is stored in a ref and cleared on cleanup.
  • Focus and blur support -- the trigger responds to onFocus/onBlur in addition to mouse events, ensuring keyboard users see the tooltip when they Tab to the trigger.
  • aria-describedby linkage -- the trigger references the tooltip's id via aria-describedby only when the tooltip is visible, so screen readers read the tooltip text as supplementary information.
  • Escape key dismissal -- a document-level keydown listener blurs the trigger on Escape, which fires onBlur and hides the tooltip without needing a separate close callback.
  • CSS arrow -- each placement has a matching arrow built from border tricks, keeping the tooltip visually connected to the trigger without external assets.

Gotchas

  • Tooltip on a disabled button -- disabled buttons do not fire mouse events in most browsers. Wrap the button in a <span> and attach the tooltip to the span instead.

  • Overflow clipping without a portal -- if the tooltip is positioned with absolute inside a parent with overflow:hidden or overflow:auto, the tooltip gets clipped. Use createPortal or position:fixed to escape the containing block.

  • Touch devices have no hover -- tooltips triggered by mouseEnter are invisible on touch devices. Consider showing the information inline or using a tap-to-toggle pattern for mobile.

  • Tooltip blocking interaction with nearby elements -- a large tooltip can overlap adjacent buttons or links. Add pointer-events-none to the tooltip container so clicks pass through it.

  • Flickering when moving between trigger and tooltip -- if there is a gap between the trigger and the tooltip (from margin), the mouse briefly leaves both elements, causing the tooltip to hide and re-show. Reduce the gap or add a transparent "bridge" element to maintain hover continuity.

  • Missing role="tooltip" and aria-describedby -- without these attributes, the tooltip is invisible to screen readers. Always add role="tooltip" on the popup and link it to the trigger with aria-describedby.

  • Too many tooltips causing layout shifts -- rendering many tooltips simultaneously (e.g., in a data grid) can impact performance. Use a single shared tooltip instance that repositions itself based on which element is hovered.

  • Button -- Common trigger elements for tooltips
  • Dropdown -- Another floating element with similar positioning logic
  • Modal -- For content that needs user interaction, not just passive information