Popover
Um painel flutuante acionado por um clique que exibe conteúdo rico, como formulários, detalhes adicionais ou menus de ação, posicionado em relação ao seu elemento de trigger.
Busque em todas as páginas da documentação
Um painel flutuante acionado por um clique que exibe conteúdo rico, como formulários, detalhes adicionais ou menus de ação, posicionado em relação ao seu elemento de 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>
);
}Um popover de alternância mínimo. O painel é posicionado com absolute em relação ao wrapper relative. Clicar no trigger alterna a visibilidade. Isso precisa de "use client" por causa do 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>
);
}Adiciona comportamento de fechar ao clicar fora com um listener mousedown no document. O listener é anexado apenas enquanto o popover está aberto e limpo ao fechar ou desmontar para evitar vazamentos de memória.
"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">
Adicionar uma nota
</label>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Digite aqui..."
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"
>
Salvar
</button>
</div>
</form>
</div>
)}
</div>
);
}Foca automaticamente no input quando o popover abre para digitação imediata. O formulário é resetado e fechado ao submeter. Cancelar fecha sem submeter. Este é um padrão comum para edição inline e fluxos de adição 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>
);
}A seta é um quadrado rotacionado em 45 graus posicionado no centro superior do painel. As classes border-l border-t desenham apenas as duas bordas voltadas para o trigger, criando um efeito de ponteiro limpo. O painel usa left-1/2 -translate-x-1/2 para centralizar abaixo do 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>
);
}Uma variante controlada onde o componente pai gerencia o estado open através de open e onOpenChange. Isso permite coordenar múltiplos popovers (fechando um quando outro abre) ou acionar o popover a partir de eventos externos. O suporte à tecla Escape é incluído para acessibilidade.
"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">Engenheiro de Software</p>
</div>
</div>
</HoverCard>Um cartão acionado por hover com um atraso configurável para evitar cintilação durante movimentos casuais do mouse. O timeout é limpo ao sair do mouse para evitar que o cartão abra após o cursor já ter se afastado.
"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>
);
}Um mapa de posição estático traduz nomes de posicionamento em combinações de utilitários Tailwind. Cada posição centraliza o painel no eixo perpendicular usando transformações de translate. As classes de margem (mt-2, mb-2, ml-2, mr-2) criam espaçamento consistente entre o trigger e o painel.
"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("Os componentes compostos do Popover devem ser usados 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">Fechar</span>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
);
}Aspectos Chave:
Popover, PopoverTrigger, PopoverContent e PopoverClose se comunicam via contexto, dando aos consumidores controle total sobre layout e composição sem prop drilling.open é fornecido, ele age como controlado; caso contrário, gerencia seu próprio estado com defaultOpen.aria-expanded, aria-haspopup="dialog" e aria-controls para anunciar a relação do popover. O painel de conteúdo tem role="dialog" e aria-labelledby ligando de volta ao trigger.PopoverClose retorna o foco para o botão trigger, mantendo um fluxo de navegação por teclado previsível.useId do React gera IDs estáveis e seguros para SSR para atributos ARIA, evitando descompassos de hidratação entre renderizações do servidor e do cliente.Popover cortado por ancestral com overflow: hidden -- se qualquer elemento pai tiver overflow: hidden, o painel do popover será cortado. Use um portal React (createPortal) para renderizar o painel no nível do corpo do documento.
Manipulador de clique fora fecha o popover imediatamente ao clicar no trigger -- se o listener de clique fora disparar antes da lógica de alternância, o popover abre e fecha no mesmo frame. Exclua o elemento trigger da verificação de clique externo.
Conflitos de empilhamento z-index -- um popover z-50 pode renderizar atrás de um modal com z-50. Estabeleça uma escala de z-index em seu projeto (por exemplo, dropdowns em 40, popovers em 50, modais em 60) e use-a consistentemente.
Posicionamento perto das bordas da viewport -- um popover posicionado estaticamente pode transbordar para fora da tela quando o trigger está perto da borda. Para uso em produção, considere uma biblioteca de posicionamento como Floating UI para auto-inverter e deslocar o painel.
Falta de tratamento da tecla Escape -- os usuários esperam que a tecla Escape feche painéis flutuantes. Esquecer o listener keydown para Escape quebra a acessibilidade do teclado e falha no WCAG 2.1 SC 1.2.1.
Hover card inacessível em dispositivos de toque -- popovers acionados por hover são inacessíveis em telas de toque. Sempre forneça um fallback de clique/toque ou use onPointerDown em vez de onMouseEnter.
Vazamento de memória de listeners de eventos não removidos -- anexar document.addEventListener sem uma função de limpeza em useEffect faz com que os listeners se acumulem a cada ciclo de abertura/fechamento. Sempre retorne uma função de limpeza.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥