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, andTooltipContentshare state through context. This separates the trigger element from the tooltip content, allowing flexible composition. - Portal rendering --
createPortalrenders the tooltip atdocument.bodyso it escapesoverflow:hiddencontainers and avoids stacking context clipping. Position is calculated from the trigger'sgetBoundingClientRect. - 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
delayprop 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/onBlurin addition to mouse events, ensuring keyboard users see the tooltip when they Tab to the trigger. aria-describedbylinkage -- the trigger references the tooltip'sidviaaria-describedbyonly when the tooltip is visible, so screen readers read the tooltip text as supplementary information.- Escape key dismissal -- a document-level
keydownlistener blurs the trigger on Escape, which firesonBlurand 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
absoluteinside a parent withoverflow:hiddenoroverflow:auto, the tooltip gets clipped. UsecreatePortalorposition:fixedto escape the containing block. -
Touch devices have no hover -- tooltips triggered by
mouseEnterare 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-noneto 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"andaria-describedby-- without these attributes, the tooltip is invisible to screen readers. Always addrole="tooltip"on the popup and link it to the trigger witharia-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.