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.
Casos de Uso
- Mostrar una lista de acciones para una fila en una tabla (editar, eliminar, duplicar)
- Proporcionar opciones de navegación bajo un elemento de menú de encabezado
- Mostrar opciones de cuenta (perfil, configuración, cerrar sesión)
- Ofrecer opciones de ordenamiento o filtrado en una barra de herramientas
- Presentar una lista de enlaces rápidos o atajos
- Permitir seleccionar de un conjunto de opciones sin una entrada de selección completa
Implementación Más Simple
"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.
Variaciones
Dropdown Básico con Chevron
"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.
Con Iconos
"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.
Con Divisores
"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.
Anidado / Submenú
"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.
Con Navegación por Teclado
"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.
Alineado a la Derecha
"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.
Implementación Compleja
"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:
- Patrón de componente compuesto --
Dropdown,DropdownTrigger,DropdownMenu,DropdownItem,DropdownDivider, yDropdownLabelcomparten estado a través de contexto. Esto mantiene la API componible mientras encapsula el comportamiento. - Renderizado a través de portal -- el menú se renderiza a través de
createPortalendocument.bodypara escapar de contenedores overflow:hidden y contextos de apilamiento. La posición se calcula desdegetBoundingClientRectdel trigger. - Navegación completa por teclado -- implementa el patrón de menú WAI-ARIA con ciclo ArrowDown/ArrowUp, saltos Home/End, Escape para cerrar (devuelve foco al trigger), y Tab para cerrar y mover foco naturalmente.
- Gestión de foco -- al cerrar, el foco vuelve al botón de trigger a través de
triggerRef.current?.focus(). Al abrir a través del teclado, el foco se mueve al primer elemento del menú usandorequestAnimationFrame. - Elementos destructivos -- la prop
destructiverenderiza el elemento en rojo con un fondo hover rojo, advirtiendo visualmente al usuario. Esto es independiente de la propdisabled. - Indicadores de atajo de teclado -- la prop
shortcutopcional renderiza un elemento<kbd>alineado a la derecha en el elemento, coincidiendo con la convención de menú del SO. - Elementos deshabilitados -- los elementos deshabilitados reciben
opacity-40,cursor-not-allowed, y se saltan por el selector de navegación por teclado ([role=menuitem]:not(:disabled)).
Trampas Comunes
-
Click-outside no funcionando con portales -- si el menú está portalado a
document.bodypero el listener click-outside verificaref.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 aposition: fixedcon 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-haspopupyaria-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
onClickdispara 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
mouseLeaveantes 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ú.