React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

dropdownmenupopovernavigationcomponenttailwind

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, and DropdownLabel share state through context. This keeps the API composable while encapsulating behavior.
  • Portal rendering -- the menu renders via createPortal at document.body so it escapes overflow:hidden containers and stacking contexts. Position is calculated from the trigger's getBoundingClientRect.
  • 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 using requestAnimationFrame.
  • Destructive items -- the destructive prop renders the item in red with a red hover background, visually warning the user. This is independent of the disabled prop.
  • Keyboard shortcut hints -- the optional shortcut prop 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.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.

  • Button -- Trigger elements for dropdowns
  • Modal -- Overlay pattern for more complex interactions
  • Tooltip -- Another floating UI element with positioning concerns
  • Toast -- Floating notifications with shared z-index considerations