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.
Busca en todas las páginas de la documentación
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
Popover, PopoverTrigger, PopoverContent, y PopoverClose se comunican a través del contexto, dando a los consumidores control total sobre el diseño y composición sin prop drilling.open, actúa como controlado; de lo contrario, gestiona su propio estado con defaultOpen.aria-expanded, aria-haspopup="dialog", y aria-controls para anunciar la relación del popover. El panel de contenido tiene role="dialog" y aria-labelledby vinculando de vuelta al trigger.PopoverClose devuelve el focus al botón trigger, manteniendo un flujo de navegación por teclado predecible.useId de React genera IDs estables y seguros para SSR para atributos ARIA, evitando desincronizaciones de hidratación entre renderizados de servidor y cliente.Popover recortado por ancestro overflow: hidden -- si algún elemento padre tiene overflow: 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-50 puede renderizarse detrás de un modal en z-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 keydown para 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 onPointerDown en lugar de onMouseEnter.
Fuga de memoria de manejadores de eventos no eliminados -- adjuntar document.addEventListener sin una función de limpieza en useEffect causa que los manejadores se acumulen en cada ciclo abierto/cerrado. Siempre devuelve una función de limpieza.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥