Información flotante
Una pequeña etiqueta flotante que aparece al pasar el ratón o enfocar para proporcionar información complementaria sobre un elemento sin saturar la interfaz.
Busca en todas las páginas de la documentación
Una pequeña etiqueta flotante que aparece al pasar el ratón o enfocar para proporcionar información complementaria sobre un elemento sin saturar la interfaz.
🤖 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>
);
}Una información flotante mínima posicionada encima del disparador usando bottom-full y centrada con left-1/2 -translate-x-1/2. El whitespace-nowrap evita que la información flotante se ajuste a una nueva línea para texto corto. La visibilidad se activa mediante eventos mouseEnter y mouseLeave en el contenedor.
"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 posicionamiento se asigna a una combinación diferente de clases de posición y transformación. Los posicionamientos horizontales (left/right) usan -translate-y-1/2 para centrado vertical, mientras que los posicionamientos verticales (top/bottom) usan -translate-x-1/2 para centrado 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>
);
}La flecha es un div con ancho y alto cero con trucos de borde CSS. Cada posicionamiento obtiene una combinación diferente de bordes transparentes y coloreados para apuntar la flecha hacia el elemento disparador. No se necesitan assets SVG o de imagen adicionales.
"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>
);
}Un setTimeout retrasa la aparición de la información flotante para que no parpadee cuando el usuario mueve el cursor rápidamente sobre muchos elementos disparadores. El temporizador se almacena en un useRef (no en estado) para evitar re-renderizados, y se borra en mouseLeave para cancelar la información flotante si el usuario se aleja antes de que se complete el retraso.
"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">Plan Pro</p>
// <p className="mt-1 text-gray-300">Incluye proyectos ilimitados y soporte prioritario.</p>
// </div>
// }
// >
// <span className="underline decoration-dotted cursor-help">Pro</span>
// </Tooltip>Acepta ReactNode en lugar de una cadena simple, habilitando encabezados, párrafos, enlaces o incluso imágenes dentro de la información flotante. La prop maxWidth (estilo inline) limita el ancho para que el contenido largo se ajuste naturalmente en lugar de estirar la información flotante por toda la pantalla.
"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>
);
}Añade manejadores onFocus/onBlur junto a eventos del ratón para que usuarios solo de teclado vean la información flotante cuando tabule hacia el disparador. El atributo aria-describedby vincula el disparador al contenido de la información flotante, permitiendo que los lectores de pantalla lean el texto de la información flotante como descripción del elemento. El hook useId genera un ID único para evitar conflictos cuando existen múltiples información flotantes en la misma 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("Los componentes compuestos de Tooltip deben usarse dentro de <Tooltip>");
return ctx;
}
// --- Raíz ---
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>
);
}
// --- Disparador ---
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>
);
}
// --- Contenido ---
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 al desplazarse o 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]);
// Cierra al presionar 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 clave:
Tooltip, TooltipTrigger, y TooltipContent comparten estado a través de contexto. Esto separa el elemento disparador del contenido de la información flotante, permitiendo una composición flexible.createPortal renderiza la información flotante en document.body para que escape de contenedores overflow:hidden y evite corte de contexto de apilamiento. La posición se calcula desde el getBoundingClientRect del disparador.{ capture: true } para capturar eventos de desplazamiento en cualquier ancestro, no solo en la ventana.delay configurable evita el parpadeo de la información flotante cuando el usuario mueve el cursor rápidamente sobre múltiples disparadores. El temporizador se almacena en una ref y se borra en la limpieza.onFocus/onBlur además de eventos del ratón, asegurando que los usuarios de teclado vean la información flotante cuando tabule hacia el disparador.aria-describedby -- el disparador referencia el id de la información flotante mediante aria-describedby solo cuando la información flotante es visible, para que los lectores de pantalla lean el texto de la información flotante como información complementaria.keydown a nivel de documento desenfoca el disparador al presionar Escape, que activa onBlur y oculta la información flotante sin necesidad de una devolución de llamada de cierre separada.Información flotante en un botón deshabilitado -- los botones deshabilitados no activan eventos del ratón en la mayoría de navegadores. Envuelve el botón en una <span> y adjunta la información flotante a la etiqueta en su lugar.
Corte de desbordamiento sin un portal -- si la información flotante se posiciona con absolute dentro de un elemento padre con overflow:hidden u overflow:auto, la información flotante se recorta. Usa createPortal o position:fixed para escapar del bloque contenedor.
Los dispositivos táctiles no tienen hover -- las información flotante activada por mouseEnter son invisibles en dispositivos táctiles. Considera mostrar la información en línea o usar un patrón de toca-para-alternar para dispositivos móviles.
La información flotante bloquea la interacción con elementos cercanos -- una información flotante grande puede superponerse en botones o enlaces adyacentes. Añade pointer-events-none al contenedor de información flotante para que los clics pasen a través de él.
Parpadeo al moverse entre disparador e información flotante -- si hay un espacio entre el disparador y la información flotante (del margen), el ratón brevemente sale de ambos elementos, causando que la información flotante se oculte y se vuelva a mostrar. Reduce el espacio o añade un elemento "puente" transparente para mantener la continuidad del hover.
Falta de role="tooltip" y aria-describedby -- sin estos atributos, la información flotante es invisible para los lectores de pantalla. Siempre añade role="tooltip" en la ventana emergente y vinculala al disparador con aria-describedby.
Demasiadas información flotante causando cambios de diseño -- renderizar muchas información flotante simultáneamente (por ejemplo, en una cuadrícula de datos) puede afectar el rendimiento. Usa una única instancia de información flotante compartida que se reposicione según qué elemento esté siendo pasado por el ratón.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥