Dropdown
Un panel flotante de acciones u opciones que aparece al hacer clic en un botón y se cierra cuando el usuario selecciona un elemento o hace clic fuera.
Busca en todas las páginas de la documentación
Un panel flotante de acciones u opciones que aparece al hacer clic en un botón y se cierra cuando el usuario selecciona un elemento o hace clic fuera.
🤖 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>
);
}Un dropdown mínimo usando un listener mousedown a nivel de documento para detectar clics fuera. El menú se posiciona con absolute + top-full relativo al wrapper div. Cada DropdownItem es un botón de ancho completo para objetivos de clic 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>
);
}El chevron rota 180 grados cuando el dropdown está abierto usando rotate-180 y transition-transform de Tailwind. Esto proporciona un indicador visual claro del estado abierto.
"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 un Dropdown:
// <DropdownItem
// icon={<svg className="h-4 w-4" ...>...</svg>}
// label="Editar"
// onClick={() => handleEdit()}
// />Los iconos se colocan en un contenedor de tamaño fijo con shrink-0 para que permanezcan alineados incluso cuando las etiquetas varían en longitud. El text-gray-400 mantiene los iconos visualmente secundarios al texto de la etiqueta.
"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 un Dropdown:
// <DropdownLabel>Acciones</DropdownLabel>
// <DropdownItem onClick={handleEdit}>Editar</DropdownItem>
// <DropdownItem onClick={handleDuplicate}>Duplicar</DropdownItem>
// <DropdownDivider />
// <DropdownLabel>Zona de peligro</DropdownLabel>
// <DropdownItem onClick={handleDelete}>Eliminar</DropdownItem>DropdownDivider es una línea horizontal delgada con role="separator" para accesibilidad. DropdownLabel proporciona un encabezado de sección no interactivo. Juntos agrupan acciones relacionadas visual y semánticamente.
"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="Opciones">
// <DropdownItem onClick={handleCopy}>Copiar</DropdownItem>
// <SubmenuItem label="Mover a...">
// <DropdownItem onClick={() => moveTo("inbox")}>Bandeja de entrada</DropdownItem>
// <DropdownItem onClick={() => moveTo("archive")}>Archivo</DropdownItem>
// <DropdownItem onClick={() => moveTo("trash")}>Papelera</DropdownItem>
// </SubmenuItem>
// </Dropdown>El submenú se abre en mouseEnter y se posiciona con left-full top-0 para aparecer a la derecha del elemento padre. Un chevron apuntando a la derecha señala que el elemento tiene un submenú. El gap ml-1 evita que el submenú toque el menú padre.
"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 el patrón de menú WAI-ARIA. Las teclas de flecha recorren elementos, Home/End saltan al primero/último, y Escape cierra el menú. Los elementos usan role="menuitem" y tabIndex={-1} para que solo un elemento sea enfocable a la vez. El botón de trigger usa aria-haspopup y aria-expanded para comunicar el estado a tecnología asistiva.
"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>
);
}Cuando el trigger está cerca del borde derecho del viewport, un menú alineado a la izquierda se desborda fuera de pantalla. Establecer align="right" fija el menú a right-0 para que se expanda hacia la izquierda. Esto es común para menús de avatar de usuario y botones de acción en filas de tabla.
"use client";
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
KeyboardEvent,
} from "react";
import { createPortal } from "react-dom";
// --- Context ---
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("Dropdown compound components must be used inside <Dropdown>");
return ctx;
}
// --- Root ---
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 clave:
Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownDivider, y DropdownLabel comparten estado a través de contexto. Esto mantiene la API componible mientras encapsula el comportamiento.createPortal en document.body para escapar de contenedores overflow:hidden y contextos de apilamiento. La posición se calcula desde getBoundingClientRect del trigger.triggerRef.current?.focus(). Al abrir a través del teclado, el foco se mueve al primer elemento del menú usando requestAnimationFrame.destructive renderiza el elemento en rojo con un fondo hover rojo, advirtiendo visualmente al usuario. Esto es independiente de la prop disabled.shortcut opcional renderiza un elemento <kbd> alineado a la derecha en el elemento, coincidiendo con la convención de menú del SO.opacity-40, cursor-not-allowed, y se saltan por el selector de navegación por teclado ([role=menuitem]:not(:disabled)).Click-outside no funcionando con portales -- si el menú está portalado a document.body pero el listener click-outside verifica ref.contains() en el wrapper, siempre detecta el clic del menú como "afuera." Verifica ambas referencias del trigger y del menú en el manejador de clic afuera.
Menú recortado por padre overflow:hidden -- si el trigger está dentro de un contenedor con overflow-hidden, el menú posicionado absolutamente se corta. Usa un portal o cambia a position: fixed con coordenadas calculadas.
Guerras de z-index con otros elementos flotantes -- dropdowns, tooltips, modales, y toasts todos compiten por z-index. Establece una escala de z-index consistente (por ejemplo, dropdown=50, modal=60, toast=70) y documéntala.
Olvidar aria-haspopup y aria-expanded -- sin estos atributos, los lectores de pantalla no pueden comunicar que el botón abre un menú o si el menú está actualmente abierto.
Cerrar al hacer clic en elemento pero no actualizar estado -- si onClick dispara una acción asincrónica y el dropdown se cierra antes de completarse, asegúrate de que la acción no dependa del dropdown siendo montado (por ejemplo, evita referencias a elementos internos del dropdown en el callback).
Tiempo de hover de submenú -- en menús anidados, un movimiento rápido del ratón del padre al hijo puede dejar brevemente ambos elementos, haciendo que el submenú se cierre. Añade un pequeño retraso (150-200ms) en mouseLeave antes de cerrar.
Conflictos de focus trap con modales -- si un dropdown se usa dentro de un modal con un focus trap, abrir el menú portalado mueve el foco fuera de la trampa. O renderiza el dropdown dentro del DOM del modal (sin portal) o ajusta el focus trap para incluir el menú.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥