--- name: dropdown-menu description: Creates dropdown menus with proper click-outside detection and z-index stacking for list contexts. Use when building action menus, context menus, or any dropdown that appears in cards/list items. --- # Dropdown Menu Pattern Build dropdown menus that work correctly in list/card contexts, handling z-index stacking and click-outside dismissal properly. ## Why This Pattern? Dropdown menus in list items have three common bugs: 1. **Clipped by parent's `overflow-hidden`** - dropdown gets cut off 2. **Covered by sibling cards** - z-index doesn't help across stacking contexts 3. **Double-toggle on trigger click** - menu closes then reopens immediately This pattern solves all three. ## Core Implementation ```tsx "use client"; import { useState, useRef, useEffect } from "react"; import { MoreVertical, Pause, X } from "lucide-react"; // The dropdown menu component function DropdownMenu({ dark = false, onClose, }: { dark?: boolean; onClose: () => void; }) { const menuRef = useRef(null); useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { onClose(); } } // IMPORTANT: Use "click" not "mousedown" to allow stopPropagation on trigger document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [onClose]); return (
); } ``` ## Key Elements ### 1. Click-Outside Detection (Use `click`, NOT `mousedown`) ```tsx // CORRECT - allows stopPropagation on trigger button document.addEventListener("click", handleClickOutside); // WRONG - fires before button's onClick, causing double-toggle document.addEventListener("mousedown", handleClickOutside); ``` **Why?** With `mousedown`, the sequence is: 1. mousedown fires → click-outside closes menu 2. click fires on button → toggle reopens menu With `click`, `stopPropagation()` on the button prevents the document listener from firing. ### 2. Parent Card Z-Index Elevation When menu is open, elevate the entire parent card above siblings: ```tsx
{/* card content with dropdown inside */}
``` **Why?** Each card creates its own stacking context. The dropdown's `z-20` only applies within its card. Sibling cards rendered later in the DOM naturally stack on top. ### 3. Avoid `overflow-hidden` on Dropdown Containers ```tsx // BAD - clips dropdown regardless of z-index
// GOOD - only use overflow-hidden where needed (e.g., expandable sections)
{/* expandable content only */}
``` ### 4. Trigger Button with stopPropagation ```tsx
{menuOpen && onMenuClose && }
``` Note the `-m-1.5` negative margin - this increases the clickable area without affecting layout. ## Full Card Example with Dropdown ```tsx interface CardProps { title: string; menuOpen?: boolean; onMenuToggle?: () => void; onMenuClose?: () => void; } function Card({ title, menuOpen = false, onMenuToggle, onMenuClose }: CardProps) { return (
console.log("card clicked")} >
{title}
{menuOpen && onMenuClose && }
); } // Parent component managing which menu is open function CardList() { const [openMenu, setOpenMenu] = useState(null); const items = ["Item 1", "Item 2", "Item 3"]; return (
{items.map((item, index) => ( setOpenMenu(openMenu === index ? null : index)} onMenuClose={() => setOpenMenu(null)} /> ))}
); } ``` ## Menu Positioning Options ```tsx // Below, right-aligned (default) className="absolute right-0 top-full mt-1" // Below, left-aligned className="absolute left-0 top-full mt-1" // Above, right-aligned className="absolute right-0 bottom-full mb-1" // Above, left-aligned className="absolute left-0 bottom-full mb-1" ``` ## Related: Tooltips in Stacked Items When showing tooltips on items that have varying z-indexes (like stacked cards), the tooltip will be trapped in its parent's stacking context. The solution is to render the tooltip **outside** the item loop as a sibling element, calculating its position based on which item is hovered. See the **stacked-cards** skill for the full pattern. ```tsx // WRONG - Tooltip trapped in parent's z-index {items.map((item, i) => (
{hovered === i && } {/* Trapped! */}
))} // CORRECT - Tooltip outside the loop {items.map((item, i) => (
))} {hovered !== null && ( )} ``` ## Checklist - [ ] Click-outside uses `click` event (not `mousedown`) - [ ] Parent card has conditional `z-30` when menu is open - [ ] No `overflow-hidden` on containers that hold the dropdown - [ ] Trigger button has `stopPropagation()` in onClick - [ ] Menu items have `stopPropagation()` in onClick - [ ] Trigger wrapper has `relative` positioning - [ ] Dropdown has `absolute` positioning with `top-full` or `bottom-full` - [ ] For stacked items, tooltip rendered outside the loop (see stacked-cards skill)