Tooltip
Um pequeno rótulo flutuante que aparece ao passar o mouse ou focar para fornecer informações suplementares sobre um elemento sem sobrecarregar a interface.
Busque em todas as páginas da documentação
Um pequeno rótulo flutuante que aparece ao passar o mouse ou focar para fornecer informações suplementares sobre um elemento sem sobrecarregar a interface.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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>
);
}Um tooltip mínimo posicionado acima do gatilho usando bottom-full e centralizado com left-1/2 -translate-x-1/2. O whitespace-nowrap impede que o tooltip quebre para uma nova linha para textos curtos. A visibilidade é alternada por eventos mouseEnter e mouseLeave no wrapper.
"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>
);
}Cada posicionamento mapeia para uma combinação diferente de classes de posicionamento e transformação. Posicionamentos horizontais (left/right) usam -translate-y-1/2 para centralização vertical, enquanto posicionamentos verticais (top/bottom) usam -translate-x-1/2 para centralização horizontal.
"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>
);
}A seta é um div de largura e altura zero com truques de borda CSS. Cada posicionamento recebe uma combinação diferente de bordas transparentes e coloridas para apontar a seta para o elemento gatilho. Nenhum SVG ou imagem extra é necessário.
"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>
);
}Um setTimeout atrasa a aparição do tooltip para que ele não pisque quando o usuário move o cursor rapidamente por vários elementos gatilho. O timer é armazenado em um useRef (não em estado) para evitar re-renderizações e é limpo na mouseLeave para cancelar o tooltip se o usuário se afastar antes que o atraso complete.
"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>
);
}
// Uso:
// <Tooltip
// content={
// <div>
// <p className="font-semibold">Plano Pro</p>
// <p className="mt-1 text-gray-300">Inclui projetos ilimitados e suporte prioritário.</p>
// </div>
// }
// >
// <span className="underline decoration-dotted cursor-help">Pro</span>
// </Tooltip>Aceita ReactNode em vez de uma string simples, permitindo títulos, parágrafos, links ou até mesmo imagens dentro do tooltip. A prop maxWidth (estilo inline) limita a largura para que o conteúdo longo quebre naturalmente em vez de esticar o tooltip pela tela.
"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>
);
}Adiciona manipuladores onFocus/onBlur além dos eventos de mouse para que usuários apenas de teclado vejam o tooltip ao pressionar Tab no gatilho. O atributo aria-describedby vincula o gatilho ao conteúdo do tooltip, permitindo que leitores de tela leiam o texto do tooltip como a descrição do elemento. O hook useId gera um ID único para evitar conflitos quando vários tooltips existem na mesma página.
"use client";
import {
useState,
useRef,
useEffect,
useCallback,
useId,
createContext,
useContext,
} from "react";
import { createPortal } from "react-dom";
// --- Tipos ---
type Placement = "top" | "bottom" | "left" | "right";
interface TooltipContextValue {
open: boolean;
show: () => void;
hide: () => void;
placement: Placement;
triggerRef: React.RefObject<HTMLDivElement | null>;
tooltipId: string;
}
// --- Contexto ---
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext() {
const ctx = useContext(TooltipContext);
if (!ctx) throw new Error("Componentes compostos de Tooltip devem ser usados dentro de <Tooltip>");
return ctx;
}
// --- Raiz ---
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>
);
}
// --- Gatilho ---
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>
);
}
// --- Conteúdo ---
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]);
// Reposiciona ao rolar ou redimensionar
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]);
// Fecha ao pressionar 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
);
}Aspectos chave:
Tooltip, TooltipTrigger e TooltipContent compartilham estado através de contexto. Isso separa o elemento gatilho do conteúdo do tooltip, permitindo composição flexível.createPortal renderiza o tooltip em document.body para que ele escape de contêineres com overflow:hidden e evite clipping de contexto de empilhamento. A posição é calculada a partir do getBoundingClientRect do gatilho.{ capture: true } para capturar eventos de scroll em qualquer ancestral, não apenas na janela.delay configurável evita o piscar do tooltip quando o usuário move o cursor rapidamente entre vários gatilhos. O timer é armazenado em um ref e limpo na limpeza.onFocus/onBlur além dos eventos de mouse, garantindo que usuários de teclado vejam o tooltip ao pressionar Tab no gatilho.aria-describedby -- o gatilho referencia o id do tooltip via aria-describedby apenas quando o tooltip está visível, para que leitores de tela leiam o texto do tooltip como informação suplementar.keydown em nível de documento desabilita o gatilho ao pressionar Escape, o que dispara onBlur e oculta o tooltip sem precisar de um callback de fechamento separado.Tooltip em um botão desativado -- botões desativados não disparam eventos de mouse na maioria dos navegadores. Envolva o botão em um <span> e anexe o tooltip ao span em vez disso.
Clipping de overflow sem portal -- se o tooltip for posicionado com absolute dentro de um pai com overflow:hidden ou overflow:auto, o tooltip será cortado. Use createPortal ou position:fixed para escapar do bloco de contenção.
Dispositivos com tela sensível ao toque não têm hover -- tooltips acionados por mouseEnter são invisíveis em dispositivos com tela sensível ao toque. Considere exibir as informações inline ou usar um padrão de toque para alternar em dispositivos móveis.
Tooltip bloqueando interação com elementos próximos -- um tooltip grande pode sobrepor botões ou links adjacentes. Adicione pointer-events-none ao contêiner do tooltip para que os cliques passem por ele.
Piscar ao mover entre o gatilho e o tooltip -- se houver uma lacuna entre o gatilho e o tooltip (devido à margem), o mouse deixa brevemente ambos os elementos, fazendo com que o tooltip desapareça e reapareça. Reduza a lacuna ou adicione um elemento "ponte" transparente para manter a continuidade do hover.
Falta de role="tooltip" e aria-describedby -- sem esses atributos, o tooltip é invisível para leitores de tela. Sempre adicione role="tooltip" no popup e vincule-o ao gatilho com aria-describedby.
Muitos tooltips causando deslocamentos de layout -- renderizar muitos tooltips simultaneamente (por exemplo, em uma grade de dados) pode afetar o desempenho. Use uma única instância de tooltip compartilhada que se reposiciona com base em qual elemento está sendo passado o mouse.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥