Popover
Un panel flotante disparado por un clic que muestra contenido rico como formularios, detalles adicionales o menús de acción, posicionado relativo a su elemento trigger.
Casos de uso
- Muestra una tarjeta de perfil miniatura al hacer clic en un avatar de usuario
- Muestra un selector de color o un selector de fecha en un panel flotante
- Presenta un formulario de edición rápida sin navegar a una nueva página
- Proporciona ayuda contextual o información adicional bajo demanda
- Construye un menú de compartir con enlaces sociales y copiar al portapapeles
- Muestra controles de filtro en un panel flotante sobre una tabla de datos
- Muestra detalles de notificación al hacer clic en un icono de notificación
Implementación más simple
"use client";
import { useState, useRef } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function Popover({ trigger, children }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-64 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
{children}
</div>
)}
</div>
);
}Un popover de alternancia mínimo. El panel se posiciona con absolute relativo al wrapper relative. Hacer clic en el trigger alterna la visibilidad. Esto necesita "use client" por useState.
Variaciones
Clic básico para abrir
"use client";
import { useState, useRef, useEffect } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function Popover({ trigger, children, className }: PopoverProps) {
const [open, setOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${className ?? ""}`}
>
{children}
</div>
)}
</div>
);
}Añade comportamiento de clic externo para cerrar con un manejador mousedown en document. El manejador solo se adjunta mientras el popover está abierto y se limpia al cerrar o desmontar para evitar fugas de memoria.
Con formulario dentro
"use client";
import { useState, useRef, useEffect } from "react";
interface PopoverFormProps {
trigger: React.ReactNode;
onSubmit: (value: string) => void;
}
export function PopoverForm({ trigger, onSubmit }: PopoverFormProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit(value);
setValue("");
setOpen(false);
}
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-700">
Agregar una nota
</label>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Escribe aquí..."
className="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
Cancelar
</button>
<button
type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Guardar
</button>
</div>
</form>
</div>
)}
</div>
);
}Enfoca automáticamente la entrada cuando el popover se abre para mecanografía inmediata. El formulario se reinicia y cierra al enviar. Cancelar cierra sin enviar. Este es un patrón común para edición en línea y flujos de adición rápida.
Con flecha
"use client";
import { useState } from "react";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function PopoverWithArrow({ trigger, children }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div className="absolute left-1/2 top-full z-50 mt-3 w-64 -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
<div className="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 border-l border-t border-gray-200 bg-white" />
{children}
</div>
)}
</div>
);
}La flecha es un cuadrado rotado 45 grados posicionado en el centro superior del panel. El border-l border-t dibuja solo los dos bordes que enfrentan el trigger, creando un efecto de puntero limpio. El panel usa left-1/2 -translate-x-1/2 para centrarse debajo del trigger.
Abierto/Cerrado controlado
"use client";
import { useRef, useEffect } from "react";
interface PopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function Popover({ open, onOpenChange, trigger, children, className }: PopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onOpenChange(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onOpenChange(false);
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, onOpenChange]);
return (
<div ref={popoverRef} className="relative inline-block">
<button type="button" onClick={() => onOpenChange(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${className ?? ""}`}
>
{children}
</div>
)}
</div>
);
}Una variante controlada donde el padre posee el estado open mediante open y onOpenChange. Esto permite coordinar múltiples popovers (cerrar uno cuando otro se abre) o disparar el popover desde eventos externos. La compatibilidad con la tecla Escape se incluye para accesibilidad.
Estilo de tarjeta de desplazamiento
"use client";
import { useState, useRef } from "react";
interface HoverCardProps {
trigger: React.ReactNode;
children: React.ReactNode;
delay?: number;
}
export function HoverCard({ trigger, children, delay = 300 }: HoverCardProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleMouseEnter() {
timeoutRef.current = setTimeout(() => setOpen(true), delay);
}
function handleMouseLeave() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(false);
}
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{trigger}
{open && (
<div className="absolute left-1/2 top-full z-50 mt-2 w-80 -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-4 shadow-xl">
{children}
</div>
)}
</div>
);
}
// Uso
<HoverCard
trigger={<span className="cursor-pointer font-medium text-blue-600 underline">@johndoe</span>}
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div>
<p className="font-semibold">John Doe</p>
<p className="text-sm text-gray-500">Ingeniero de Software</p>
</div>
</div>
</HoverCard>Una tarjeta disparada por desplazamiento con un retraso configurable para evitar parpadeos durante el movimiento casual del ratón. El tiempo de espera se borra al salir del ratón para evitar que la tarjeta se abra después de que el cursor ya se haya alejado.
Posicionado (Arriba/Abajo/Izquierda/Derecha)
"use client";
import { useState } from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
position?: PopoverPosition;
}
const positionClasses: Record<PopoverPosition, 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 Popover({ trigger, children, position = "bottom" }: PopoverProps) {
const [open, setOpen] = useState(false);
return (
<div className="relative inline-block">
<button type="button" onClick={() => setOpen(!open)}>
{trigger}
</button>
{open && (
<div
className={`absolute z-50 w-64 rounded-lg border border-gray-200 bg-white p-4 shadow-lg ${positionClasses[position]}`}
>
{children}
</div>
)}
</div>
);
}Un mapa de posición estática traduce nombres de ubicación en combinaciones de utilidades de Tailwind. Cada posición centra el panel a lo largo del eje perpendicular utilizando transformaciones de traducción. Las clases de margen (mt-2, mb-2, ml-2, mr-2) crean espaciado consistente entre el trigger y el panel.
Implementación compleja
"use client";
import {
forwardRef,
useState,
useRef,
useEffect,
useCallback,
useId,
createContext,
useContext,
type ReactNode,
} from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
interface PopoverContextValue {
open: boolean;
setOpen: (v: boolean) => void;
triggerId: string;
contentId: string;
position: PopoverPosition;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const PopoverContext = createContext<PopoverContextValue | null>(null);
function usePopoverContext() {
const ctx = useContext(PopoverContext);
if (!ctx) throw new Error("Los componentes compuestos Popover deben usarse dentro de <Popover>");
return ctx;
}
interface PopoverProps {
children: ReactNode;
position?: PopoverPosition;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Popover({
children,
position = "bottom",
defaultOpen = false,
open: controlledOpen,
onOpenChange,
}: PopoverProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const id = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const setOpen = useCallback(
(value: boolean) => {
if (!isControlled) setUncontrolledOpen(value);
onOpenChange?.(value);
},
[isControlled, onOpenChange]
);
return (
<PopoverContext.Provider
value={{
open,
setOpen,
triggerId: `${id}-trigger`,
contentId: `${id}-content`,
position,
triggerRef,
}}
>
<div className="relative inline-block">{children}</div>
</PopoverContext.Provider>
);
}
export const PopoverTrigger = forwardRef<HTMLButtonElement, { children: ReactNode }>(
function PopoverTrigger({ children }, ref) {
const { open, setOpen, triggerId, contentId, triggerRef } = usePopoverContext();
return (
<button
ref={(node) => {
triggerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
id={triggerId}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? contentId : undefined}
onClick={() => setOpen(!open)}
>
{children}
</button>
);
}
);
const positionClasses: Record<PopoverPosition, 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",
};
interface PopoverContentProps {
children: ReactNode;
className?: string;
}
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
function PopoverContent({ children, className }, ref) {
const { open, setOpen, contentId, triggerId, position, triggerRef } =
usePopoverContext();
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (
contentRef.current &&
!contentRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
setOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
return (
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
id={contentId}
role="dialog"
aria-labelledby={triggerId}
className={`absolute z-50 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 ${positionClasses[position]} ${className ?? ""}`}
>
{children}
</div>
);
}
);
export function PopoverClose({ children }: { children: ReactNode }) {
const { setOpen, triggerRef } = usePopoverContext();
return (
<button
type="button"
onClick={() => {
setOpen(false);
triggerRef.current?.focus();
}}
>
{children}
</button>
);
}
// Uso
function ProfilePopover() {
return (
<Popover position="bottom">
<PopoverTrigger>
<span className="rounded-md bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200">
Perfil
</span>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-blue-100" />
<div>
<p className="font-semibold">Jane Doe</p>
<p className="text-sm text-gray-500">jane@example.com</p>
</div>
</div>
<div className="mt-3 flex justify-end">
<PopoverClose>
<span className="text-sm text-gray-500 hover:text-gray-700">Cerrar</span>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
);
}Aspectos clave:
- Patrón de componente compuesto --
Popover,PopoverTrigger,PopoverContent, yPopoverClosese comunican a través del contexto, dando a los consumidores control total sobre el diseño y composición sin prop drilling. - Modos controlado y no controlado -- el componente soporta ambos patrones. Cuando se proporciona
open, actúa como controlado; de lo contrario, gestiona su propio estado condefaultOpen. - Atributos ARIA -- el trigger usa
aria-expanded,aria-haspopup="dialog", yaria-controlspara anunciar la relación del popover. El panel de contenido tienerole="dialog"yaria-labelledbyvinculando de vuelta al trigger. - Gestión de focus al cerrar -- presionar Escape o hacer clic en
PopoverClosedevuelve el focus al botón trigger, manteniendo un flujo de navegación por teclado predecible. - Detección de clic externo excluye trigger -- el manejador de clic externo verifica que el objetivo del clic esté fuera del contenido y del trigger, evitando que el popover se cierre y se reabre inmediatamente cuando se hace clic en el trigger.
- IDs únicos vía useId --
useIdde React genera IDs estables y seguros para SSR para atributos ARIA, evitando desincronizaciones de hidratación entre renderizados de servidor y cliente.
Trampas
-
Popover recortado por ancestro
overflow: hidden-- si algún elemento padre tieneoverflow: hidden, el panel popover se corta. Usa un portal de React (createPortal) para renderizar el panel al nivel del body del documento. -
El manejador de clic externo cierra el popover inmediatamente al hacer clic en trigger -- si el manejador de clic externo se dispara antes de la lógica de alternancia, el popover se abre y cierra en el mismo fotograma. Excluye el elemento trigger del chequeo de clic externo.
-
Conflictos de apilamiento de z-index -- un popover
z-50puede renderizarse detrás de un modal enz-50. Establece una escala de z-index en tu proyecto (p. ej., desplegables en 40, popovers en 50, modales en 60) y úsala consistentemente. -
Posicionamiento cerca de los bordes del viewport -- un popover posicionado estáticamente puede desbordarse fuera de la pantalla cuando el trigger está cerca del borde. Para uso en producción, considera una biblioteca de posicionamiento como Floating UI para auto-voltear y desplazar el panel.
-
Manejo de tecla Escape faltante -- los usuarios esperan que Escape cierre paneles flotantes. Olvidar el manejador
keydownpara Escape rompe la accesibilidad del teclado y falla en WCAG 2.1 SC 1.2.1. -
Tarjeta de desplazamiento inaccesible en dispositivos táctiles -- los popovers disparados por desplazamiento son inalcanzables en pantallas táctiles. Siempre proporciona un fallback de clic/tap o usa
onPointerDownen lugar deonMouseEnter. -
Fuga de memoria de manejadores de eventos no eliminados -- adjuntar
document.addEventListenersin una función de limpieza enuseEffectcausa que los manejadores se acumulen en cada ciclo abierto/cerrado. Siempre devuelve una función de limpieza.
Relacionado
- Tooltip -- Los tooltips muestran texto breve al pasar el ratón; los popovers muestran contenido rico al hacer clic
- Dropdown -- Los desplegables son un popover especializado para seleccionar de una lista
- Modal -- Los modales bloquean la interacción con la página; los popovers no son modales
- Toast -- Los toasts muestran mensajes transitorios; los popovers muestran contenido persistente hasta ser descartados