Dropdown
A floating panel of actions or options that appears on button click and closes when the user selects an item or clicks outside.
Use Cases
- Display a list of actions for a row in a table (edit, delete, duplicate)
- Provide navigation options under a header menu item
- Show account options (profile, settings, sign out)
- Offer sort or filter choices in a toolbar
- Present a list of quick links or shortcuts
- Allow selection from a set of options without a full select input
Simplest Implementation
"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.
Variations
Basic Dropdown with 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>
);
}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.
With Icons
"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.
With Dividers
"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.
Nested / Submenu
"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.
With Keyboard Navigation
"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.
Right-Aligned
"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.
Complex Implementation
"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:
- Compound component pattern --
Dropdown,DropdownTrigger,DropdownMenu,DropdownItem,DropdownDivider, andDropdownLabelshare state through context. This keeps the API composable while encapsulating behavior. - Portal rendering -- the menu renders via
createPortalatdocument.bodyso it escapes overflow:hidden containers and stacking contexts. Position is calculated from the trigger'sgetBoundingClientRect. - Full keyboard navigation -- implements the WAI-ARIA menu pattern with ArrowDown/ArrowUp cycling, Home/End jumps, Escape to close (returns focus to trigger), and Tab to close and move focus naturally.
- Focus management -- on close, focus returns to the trigger button via
triggerRef.current?.focus(). On open via keyboard, focus moves to the first menu item usingrequestAnimationFrame. - Destructive items -- the
destructiveprop renders the item in red with a red hover background, visually warning the user. This is independent of thedisabledprop. - Keyboard shortcut hints -- the optional
shortcutprop renders a<kbd>element right-aligned in the item, matching the OS menu convention. - Disabled items -- disabled items receive
opacity-40,cursor-not-allowed, and are skipped by the keyboard navigation selector ([role=menuitem]:not(:disabled)).
Gotchas
-
Click-outside not working with portals -- if the menu is portaled to
document.bodybut the click-outside listener checksref.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 toposition: fixedwith 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-haspopupandaria-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
onClicktriggers 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
mouseLeavebefore 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.