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.
Casos de uso
- Explicar botones con solo icono (editar, eliminar, configuración)
- Mostrar el texto completo de una etiqueta o celda truncada
- Mostrar atajos de teclado junto a acciones de barra de herramientas
- Proporcionar contexto adicional para campos de formulario o indicadores de estado
- Revelar marcas de tiempo o metadatos en elementos de interfaz compactos
- Aclarar acciones deshabilitadas o restringidas
Implementación más simple
"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.
Variaciones
Posicionamiento Arriba / Abajo / Izquierda / Derecha
"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.
Con flecha
"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.
Presentación con retraso
"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.
Información flotante con contenido enriquecido
"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.
Al enfocar (Accesible)
"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.
Implementación compleja
"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:
- Patrón de componente compuesto --
Tooltip,TooltipTrigger, yTooltipContentcomparten estado a través de contexto. Esto separa el elemento disparador del contenido de la información flotante, permitiendo una composición flexible. - Renderizado de portal --
createPortalrenderiza la información flotante endocument.bodypara que escape de contenedoresoverflow:hiddeny evite corte de contexto de apilamiento. La posición se calcula desde elgetBoundingClientRectdel disparador. - Reposicionamiento dinámico -- los oyentes de desplazamiento y redimensionamiento recalculan la posición de la información flotante mientras es visible. El oyente de desplazamiento usa
{ capture: true }para capturar eventos de desplazamiento en cualquier ancestro, no solo en la ventana. - Presentación retrasada -- una prop
delayconfigurable 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. - Soporte de enfoque y desenfoque -- el disparador responde a
onFocus/onBlurademás de eventos del ratón, asegurando que los usuarios de teclado vean la información flotante cuando tabule hacia el disparador. - Vinculación de
aria-describedby-- el disparador referencia elidde la información flotante mediantearia-describedbysolo 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. - Despido de tecla Escape -- un oyente
keydowna nivel de documento desenfoca el disparador al presionar Escape, que activaonBlury oculta la información flotante sin necesidad de una devolución de llamada de cierre separada. - Flecha CSS -- cada posicionamiento tiene una flecha coincidente construida a partir de trucos de borde, manteniendo la información flotante visualmente conectada al disparador sin assets externos.
Errores comunes
-
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
absolutedentro de un elemento padre conoverflow:hiddenuoverflow:auto, la información flotante se recorta. UsacreatePortaloposition:fixedpara escapar del bloque contenedor. -
Los dispositivos táctiles no tienen hover -- las información flotante activada por
mouseEnterson 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-noneal 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"yaria-describedby-- sin estos atributos, la información flotante es invisible para los lectores de pantalla. Siempre añaderole="tooltip"en la ventana emergente y vinculala al disparador conaria-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.
Relacionado
- Botón -- Elementos disparadores comunes para información flotante
- Desplegable -- Otro elemento flotante con lógica de posicionamiento similar
- Modal -- Para contenido que necesita interacción del usuario, no solo información pasiva