Dropdown
Um painel flutuante de ações ou opções que aparece ao clicar em um botão e fecha quando o usuário seleciona um item ou clica fora.
Busque em todas as páginas da documentação
Um painel flutuante de ações ou opções que aparece ao clicar em um botão e fecha quando o usuário seleciona um item ou clica fora.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useState, useRef, useEffect } from "react";
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function Dropdown({ trigger, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
return (
<div ref={ref} className="relative inline-block">
<button onClick={() => setOpen((prev) => !prev)}>{trigger}</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[10rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{children}
</div>
)}
</div>
);
}
export function DropdownItem({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
return (
<button
onClick={onClick}
className="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100"
>
{children}
</button>
);
}Um dropdown mínimo usando um listener de mousedown em nível de documento para detectar cliques externos. O menu é posicionado com absolute + top-full em relação ao div wrapper. Cada DropdownItem é um botão de largura total para alvos de clique consistentes.
"use client";
import { useState, useRef, useEffect } from "react";
interface DropdownProps {
label: string;
children: React.ReactNode;
}
export function Dropdown({ label, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
return (
<div ref={ref} className="relative inline-block">
<button
onClick={() => setOpen((prev) => !prev)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{label}
<svg
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[10rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{children}
</div>
)}
</div>
);
}O chevron gira 180 graus quando o dropdown está aberto usando rotate-180 e transition-transform do Tailwind. Isso fornece um indicador visual claro do estado aberto.
"use client";
import { useState, useRef, useEffect } from "react";
interface DropdownItemProps {
icon: React.ReactNode;
label: string;
onClick?: () => void;
}
export function DropdownItem({ icon, label, onClick }: DropdownItemProps) {
return (
<button
onClick={onClick}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100"
>
<span className="h-4 w-4 shrink-0 text-gray-400">{icon}</span>
{label}
</button>
);
}
// Uso dentro de um Dropdown:
// <DropdownItem
// icon={<svg className="h-4 w-4" ...>...</svg>}
// label="Editar"
// onClick={() => handleEdit()}
// />Ícones são colocados em um contêiner de tamanho fixo com shrink-0 para que permaneçam alinhados mesmo quando os rótulos variam em comprimento. O text-gray-400 mantém os ícones visualmente secundários ao texto do rótulo.
"use client";
import { useState, useRef, useEffect } from "react";
export function DropdownDivider() {
return <div className="my-1 h-px bg-gray-200" role="separator" />;
}
export function DropdownLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400">
{children}
</div>
);
}
// Uso dentro de um Dropdown:
// <DropdownLabel>Ações</DropdownLabel>
// <DropdownItem onClick={handleEdit}>Editar</DropdownItem>
// <DropdownItem onClick={handleDuplicate}>Duplicar</DropdownItem>
// <DropdownDivider />
// <DropdownLabel>Zona de perigo</DropdownLabel>
// <DropdownItem onClick={handleDelete}>Excluir</DropdownItem>DropdownDivider é uma linha horizontal fina com role="separator" para acessibilidade. DropdownLabel fornece um título de seção não interativo. Juntos, eles agrupam ações relacionadas visual e semanticamente.
"use client";
import { useState, useRef, useEffect } from "react";
interface SubmenuItemProps {
label: string;
children: React.ReactNode;
}
export function SubmenuItem({ label, children }: SubmenuItemProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
return (
<div
ref={ref}
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<button className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100">
<span>{label}</span>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{open && (
<div className="absolute left-full top-0 z-50 ml-1 min-w-[10rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{children}
</div>
)}
</div>
);
}
// Uso:
// <Dropdown trigger="Opções">
// <DropdownItem onClick={handleCopy}>Copiar</DropdownItem>
// <SubmenuItem label="Mover para...">
// <DropdownItem onClick={() => moveTo("inbox")}>Caixa de entrada</DropdownItem>
// <DropdownItem onClick={() => moveTo("archive")}>Arquivo</DropdownItem>
// <DropdownItem onClick={() => moveTo("trash")}>Lixeira</DropdownItem>
// </SubmenuItem>
// </Dropdown>O submenu abre em mouseEnter e se posiciona com left-full top-0 para aparecer à direita do item pai. Um chevron apontando para a direita sinaliza que o item tem um submenu. O ml-1 evita que o submenu toque o menu pai.
"use client";
import { useState, useRef, useEffect, useCallback, KeyboardEvent } from "react";
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
export function Dropdown({ trigger, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (open && menuRef.current) {
const first = menuRef.current.querySelector<HTMLButtonElement>("[role=menuitem]");
first?.focus();
}
}, [open]);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (!menuRef.current) return;
const items = Array.from(menuRef.current.querySelectorAll<HTMLButtonElement>("[role=menuitem]"));
const current = document.activeElement as HTMLButtonElement;
const index = items.indexOf(current);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
items[(index + 1) % items.length]?.focus();
break;
case "ArrowUp":
e.preventDefault();
items[(index - 1 + items.length) % items.length]?.focus();
break;
case "Escape":
setOpen(false);
break;
case "Home":
e.preventDefault();
items[0]?.focus();
break;
case "End":
e.preventDefault();
items[items.length - 1]?.focus();
break;
}
}, []);
return (
<div ref={containerRef} className="relative inline-block">
<button
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
>
{trigger}
</button>
{open && (
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
className="absolute left-0 top-full z-50 mt-1 min-w-[10rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
{children}
</div>
)}
</div>
);
}
export function DropdownItem({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) {
return (
<button
role="menuitem"
tabIndex={-1}
onClick={onClick}
className="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
>
{children}
</button>
);
}Implementa o padrão de menu WAI-ARIA. As teclas de seta percorrem os itens, Home/End pulam para o primeiro/último e Escape fecha o menu. Os itens usam role="menuitem" e tabIndex={-1} para que apenas um item seja focável por vez. O botão trigger usa aria-haspopup e aria-expanded para comunicar o estado à tecnologia assistiva.
"use client";
import { useState, useRef, useEffect } from "react";
interface DropdownProps {
trigger: React.ReactNode;
align?: "left" | "right";
children: React.ReactNode;
}
export function Dropdown({ trigger, align = "left", children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
return (
<div ref={ref} className="relative inline-block">
<button onClick={() => setOpen((prev) => !prev)}>{trigger}</button>
{open && (
<div
className={`absolute top-full z-50 mt-1 min-w-[10rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg ${
align === "right" ? "right-0" : "left-0"
}`}
>
{children}
</div>
)}
</div>
);
}Quando o trigger está perto da borda direita da viewport, um menu alinhado à esquerda transborda para fora da tela. Definir align="right" fixa o menu em right-0 para que ele se expanda para a esquerda. Isso é comum para menus de avatar de usuário e botões de ação em linhas de tabela.
"use client";
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
KeyboardEvent,
} from "react";
import { createPortal } from "react-dom";
// --- Contexto ---
interface DropdownContextValue {
open: boolean;
setOpen: (v: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
menuRef: React.RefObject<HTMLDivElement | null>;
activeIndex: number;
setActiveIndex: (i: number) => void;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
function useDropdown() {
const ctx = useContext(DropdownContext);
if (!ctx) throw new Error("Componentes compostos de Dropdown devem ser usados dentro de <Dropdown>");
return ctx;
}
// --- Raiz ---
interface DropdownProps {
children: React.ReactNode;
}
export function Dropdown({ children }: DropdownProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
setActiveIndex(-1);
return;
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<DropdownContext.Provider value={{ open, setOpen, triggerRef, menuRef, activeIndex, setActiveIndex }}>
<div className="relative inline-block">{children}</div>
</DropdownContext.Provider>
);
}
// --- Trigger ---
export function DropdownTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
const { open, setOpen, triggerRef, menuRef } = useDropdown();
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
requestAnimationFrame(() => {
const first = menuRef.current?.querySelector<HTMLButtonElement>("[role=menuitem]");
first?.focus();
});
}
},
[setOpen, menuRef]
);
return (
<button
ref={triggerRef}
onClick={() => setOpen(!open)}
onKeyDown={handleKeyDown}
aria-haspopup="menu"
aria-expanded={open}
className={className}
>
{children}
</button>
);
}
// --- Menu ---
interface DropdownMenuProps {
children: React.ReactNode;
align?: "left" | "right";
className?: string;
}
export function DropdownMenu({ children, align = "left", className }: DropdownMenuProps) {
const { open, setOpen, triggerRef, menuRef } = useDropdown();
const [coords, setCoords] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.bottom + window.scrollY + 4,
left: align === "right" ? rect.right + window.scrollX : rect.left + window.scrollX,
});
}, [open, align, triggerRef]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!menuRef.current) return;
const items = Array.from(menuRef.current.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)"));
const current = document.activeElement as HTMLButtonElement;
const index = items.indexOf(current);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
items[(index + 1) % items.length]?.focus();
break;
case "ArrowUp":
e.preventDefault();
items[(index - 1 + items.length) % items.length]?.focus();
break;
case "Escape":
e.preventDefault();
setOpen(false);
triggerRef.current?.focus();
break;
case "Home":
e.preventDefault();
items[0]?.focus();
break;
case "End":
e.preventDefault();
items[items.length - 1]?.focus();
break;
case "Tab":
setOpen(false);
break;
}
},
[setOpen, triggerRef, menuRef]
);
if (!open || !mounted) return null;
return createPortal(
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
className={`fixed z-50 min-w-[12rem] rounded-lg border border-gray-200 bg-white py-1 shadow-xl ${
align === "right" ? "-translate-x-full" : ""
} ${className ?? ""}`}
style={{ top: coords.top, left: coords.left }}
>
{children}
</div>,
document.body
);
}
// --- Item ---
interface DropdownItemProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
destructive?: boolean;
icon?: React.ReactNode;
shortcut?: string;
}
export function DropdownItem({ children, onClick, disabled, destructive, icon, shortcut }: DropdownItemProps) {
const { setOpen, triggerRef } = useDropdown();
return (
<button
role="menuitem"
tabIndex={-1}
disabled={disabled}
onClick={() => {
if (disabled) return;
onClick?.();
setOpen(false);
triggerRef.current?.focus();
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm focus:bg-gray-100 focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed ${
destructive
? "text-red-600 hover:bg-red-50"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{icon && <span className="h-4 w-4 shrink-0">{icon}</span>}
<span className="flex-1">{children}</span>
{shortcut && (
<kbd className="ml-auto text-xs text-gray-400">{shortcut}</kbd>
)}
</button>
);
}
// --- Divider ---
export function DropdownDivider() {
return <div className="my-1 h-px bg-gray-200" role="separator" />;
}
// --- Label ---
export function DropdownLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400">
{children}
</div>
);
}Aspectos Chave:
Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownDivider e DropdownLabel compartilham estado através do contexto. Isso mantém a API composável enquanto encapsula o comportamento.createPortal em document.body para que escape de contêineres com overflow:hidden e contextos de empilhamento. A posição é calculada a partir do getBoundingClientRect do trigger.triggerRef.current?.focus(). Ao abrir via teclado, o foco se move para o primeiro item do menu usando requestAnimationFrame.destructive renderiza o item em vermelho com um fundo hover vermelho, alertando visualmente o usuário. Isso é independente da prop disabled.shortcut renderiza um elemento <kbd> alinhado à direita no item, correspondendo à convenção de menu do sistema operacional.opacity-40, cursor-not-allowed e são ignorados pelo seletor de navegação por teclado ([role=menuitem]:not(:disabled)).Clique externo não funciona com portais -- se o menu é renderizado em um portal para document.body, mas o listener de clique externo verifica ref.contains() no wrapper, ele sempre detecta o clique do menu como "externo". Verifique as refs do trigger e do menu no manipulador de clique externo.
Menu cortado por pai com overflow:hidden -- se o trigger estiver dentro de um contêiner com overflow-hidden, o menu posicionado absolutamente será cortado. Use um portal ou mude para position: fixed com coordenadas calculadas.
Guerras de z-index com outros elementos flutuantes -- dropdowns, tooltips, modais e toasts competem por z-index. Estabeleça uma escala de z-index consistente (por exemplo, dropdown=50, modal=60, toast=70) e documente-a.
Esquecer aria-haspopup e aria-expanded -- sem esses atributos, os leitores de tela não podem comunicar que o botão abre um menu ou se o menu está atualmente aberto.
Fechar ao clicar no item, mas não atualizar o estado -- se onClick aciona uma ação assíncrona e o dropdown fecha antes que ela seja concluída, certifique-se de que a ação não dependa do dropdown estar montado (por exemplo, evite refs para elementos internos do dropdown no callback).
Tempo de espera do hover do submenu -- em menus aninhados, um movimento rápido do mouse do pai para o filho pode brevemente sair de ambos os elementos, fazendo com que o submenu feche. Adicione um pequeno atraso (150-200ms) em mouseLeave antes de fechar.
Conflitos de foco com modais -- se um dropdown é usado dentro de um modal com uma armadilha de foco, abrir o menu portaled move o foco para fora da armadilha. Renderize o dropdown dentro do DOM do modal (sem portal) ou ajuste a armadilha de foco para incluir o menu.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥