Dropdown
A floating panel of actions or options that appears on button click and closes when the user selects an item or clicks outside.
Search across all documentation pages
A floating panel of actions or options that appears on button click and closes when the user selects an item or clicks outside.
🤖 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>
);
}A minimal dropdown using a document-level mousedown listener to detect outside clicks. The menu is positioned with absolute + top-full relative to the wrapper div. Each DropdownItem is a full-width button for consistent click targets.
"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>
);
}The chevron rotates 180 degrees when the dropdown is open using Tailwind's rotate-180 and transition-transform. This provides a clear visual indicator of the open state.
"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>
);
}
// Usage inside a Dropdown:
// <DropdownItem
// icon={<svg className="h-4 w-4" ...>...</svg>}
// label="Edit"
// onClick={() => handleEdit()}
// />Icons are placed in a fixed-size container with shrink-0 so they stay aligned even when labels vary in length. The text-gray-400 keeps icons visually secondary to the label text.
"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>
);
}
// Usage inside a Dropdown:
// <DropdownLabel>Actions</DropdownLabel>
// <DropdownItem onClick={handleEdit}>Edit</DropdownItem>
// <DropdownItem onClick={handleDuplicate}>Duplicate</DropdownItem>
// <DropdownDivider />
// <DropdownLabel>Danger zone</DropdownLabel>
// <DropdownItem onClick={handleDelete}>Delete</DropdownItem>DropdownDivider is a thin horizontal line with role="separator" for accessibility. DropdownLabel provides a non-interactive section heading. Together they group related actions visually and semantically.
"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>
);
}
// Usage:
// <Dropdown trigger="Options">
// <DropdownItem onClick={handleCopy}>Copy</DropdownItem>
// <SubmenuItem label="Move to...">
// <DropdownItem onClick={() => moveTo("inbox")}>Inbox</DropdownItem>
// <DropdownItem onClick={() => moveTo("archive")}>Archive</DropdownItem>
// <DropdownItem onClick={() => moveTo("trash")}>Trash</DropdownItem>
// </SubmenuItem>
// </Dropdown>The submenu opens on mouseEnter and positions itself with left-full top-0 to appear to the right of the parent item. A right-pointing chevron signals that the item has a submenu. The ml-1 gap prevents the submenu from touching the parent menu.
"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>
);
}Implements the WAI-ARIA menu pattern. Arrow keys cycle through items, Home/End jump to first/last, and Escape closes the menu. Items use role="menuitem" and tabIndex={-1} so only one item is focusable at a time. The trigger button uses aria-haspopup and aria-expanded to communicate state to assistive technology.
"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>
);
}When the trigger is near the right edge of the viewport, a left-aligned menu overflows off-screen. Setting align="right" pins the menu to right-0 so it expands leftward. This is common for user avatar menus and action buttons in table rows.
"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>
);
}Key aspects:
Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownDivider, and DropdownLabel share state through context. This keeps the API composable while encapsulating behavior.createPortal at document.body so it escapes overflow:hidden containers and stacking contexts. Position is calculated from the trigger's getBoundingClientRect.triggerRef.current?.focus(). On open via keyboard, focus moves to the first menu item using requestAnimationFrame.destructive prop renders the item in red with a red hover background, visually warning the user. This is independent of the disabled prop.shortcut prop renders a <kbd> element right-aligned in the item, matching the OS menu convention.opacity-40, cursor-not-allowed, and are skipped by the keyboard navigation selector ([role=menuitem]:not(:disabled)).Click-outside not working with portals -- if the menu is portaled to document.body but the click-outside listener checks ref.contains() on the wrapper, it always detects the menu click as "outside." Check both the trigger ref and the menu ref in the outside-click handler.
Menu clipped by overflow:hidden parent -- if the trigger sits inside a container with overflow-hidden, the absolutely-positioned menu gets cut off. Use a portal or switch to position: fixed with calculated coordinates.
Z-index wars with other floating elements -- dropdowns, tooltips, modals, and toasts all compete for z-index. Establish a consistent z-index scale (e.g., dropdown=50, modal=60, toast=70) and document it.
Forgetting aria-haspopup and aria-expanded -- without these attributes, screen readers cannot communicate that the button opens a menu or whether the menu is currently open.
Closing on item click but not updating state -- if onClick triggers an async action and the dropdown closes before it completes, ensure the action does not depend on the dropdown being mounted (e.g., avoid refs to dropdown-internal elements in the callback).
Submenu hover timing -- on nested menus, a fast mouse movement from parent to child can briefly leave both elements, causing the submenu to close. Add a small delay (150-200ms) on mouseLeave before closing.
Focus trap conflicts with modals -- if a dropdown is used inside a modal with a focus trap, opening the portaled menu moves focus outside the trap. Either render the dropdown inside the modal DOM (no portal) or adjust the focus trap to include the menu.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥