Component
Dropdown Menu with Icons
Menu items with meaningful leading icons in a consistent 16px aria-hidden slot — the text label still carries the accessible name, so meaning never depends on the icon alone.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Role=menu semantics
- Focus management
- Outside-pointer close
- Viewport-aware placement
- Leading icon slot
- CurrentColor icons
- Aria-haspopup="menu" trigger
- Role="menu" panel
- Role="menuitem" items
- Aria-expanded state
- Focus restoration on close
- Native disabled items
Preview
Live component
9:41 100%
devsnips.site/library/react/components/dropdowns/dropdown-menu-with-icons/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dropdowns/dropdown-menu-with-icons React/Components/Dropdowns/dropdown-menu-with-icons import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Dropdown Menu — With Icons.
*
* The shared menu core with meaningful leading icons on items. Icons are
* rendered aria-hidden (the text label carries the accessible name), use a
* consistent 16px slot, and follow the muted-foreground token — or the
* destructive token on destructive items — so state never relies on color
* alone. Identical core + behavior as the reference dropdown-menu variant.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type DropdownMenuPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
const TRIGGER_CLASSES =
"inline-flex h-9 max-w-full items-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] aria-expanded:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const MENU_CLASSES =
"absolute z-40 max-h-[min(20rem,calc(100vh-2rem))] min-w-[12rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
// Layout/interaction only — the text/hover/focus tone is composed per item
// kind so the destructive tone never conflicts with the default tone.
const ITEM_CLASSES =
"flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ITEM_TONE_DEFAULT =
"text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)]";
const ITEM_TONE_DESTRUCTIVE =
"text-[var(--ds-color-destructive)] hover:bg-[var(--ds-color-destructive-soft)] focus:bg-[var(--ds-color-destructive-soft)]";
const ICON_SLOT_CLASSES = "inline-flex shrink-0 [&_svg]:size-4";
const SHORTCUT_CLASSES =
"ml-auto shrink-0 pl-6 text-xs leading-5 text-[var(--ds-color-muted-foreground)]";
const LABEL_CLASSES =
"px-2 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.05em] text-[var(--ds-color-muted-foreground)]";
const SEPARATOR_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const PLACEMENT_CLASSES: Record<DropdownMenuPlacement, string> = {
"bottom-start": "left-0 top-full mt-1.5",
"bottom-end": "right-0 top-full mt-1.5",
"top-start": "bottom-full left-0 mb-1.5",
"top-end": "bottom-full right-0 mb-1.5",
};
/** Items owned directly by `content` (excludes items of nested open submenus). */
function menuItems(content: HTMLElement): HTMLElement[] {
return Array.from(
content.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
),
).filter(
(el) =>
el.closest('[role="menu"]') === content &&
!el.hasAttribute("disabled") &&
el.getAttribute("aria-disabled") !== "true",
);
}
function focusItem(items: HTMLElement[], index: number): void {
if (items.length === 0) return;
const wrapped = ((index % items.length) + items.length) % items.length;
items[wrapped].focus();
}
function ChevronDown({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/* ------------------------------------------------------------------------ */
/* Root context */
/* ------------------------------------------------------------------------ */
interface DropdownMenuContextValue {
open: boolean;
initialFocus: "first" | "last";
requestOpen(next: boolean, focusTarget?: "first" | "last"): void;
closeMenu(options?: { refocus?: boolean }): void;
triggerRef: RefObject<HTMLButtonElement>;
rootRef: RefObject<HTMLDivElement>;
triggerId: string;
contentId: string;
placement: DropdownMenuPlacement;
}
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
function useDropdownMenu(component: string): DropdownMenuContextValue {
const context = useContext(DropdownMenuContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <DropdownMenu>.`);
}
return context;
}
/**
* Registry of open submenus at one menu level, so pointer interaction with a
* sibling item can close the open submenu at that level. Provided by every
* menu panel (`DropdownMenuContent`, and `DropdownMenuSubContent` in the
* submenu variant).
*/
interface MenuLevelContextValue {
registerSub(close: () => void): () => void;
closeSubs(except?: () => void): void;
}
const MenuLevelContext = createContext<MenuLevelContextValue | null>(null);
function useMenuLevel(): MenuLevelContextValue {
const subsRef = useRef<Set<() => void> | null>(null);
const levelRef = useRef<MenuLevelContextValue | null>(null);
if (!subsRef.current) subsRef.current = new Set();
if (!levelRef.current) {
const subs = subsRef.current;
levelRef.current = {
registerSub(close) {
subs.add(close);
return () => {
subs.delete(close);
};
},
closeSubs(except) {
subs.forEach((close) => {
if (close !== except) close();
});
},
};
}
return levelRef.current;
}
/* ------------------------------------------------------------------------ */
/* DropdownMenu (root) */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the menu requests to open or close. */
onOpenChange?: (open: boolean) => void;
/** Preferred placement relative to the trigger; flips to stay in the viewport. */
placement?: DropdownMenuPlacement;
className?: string;
children?: ReactNode;
}
export function DropdownMenu({
open,
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
className,
children,
}: DropdownMenuProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef<HTMLButtonElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const reactId = useId();
const triggerId = `ds-menu-trigger${reactId}`;
const contentId = `ds-menu${reactId}`;
function requestOpen(next: boolean, focusTarget: "first" | "last" = "first") {
setInitialFocus(focusTarget);
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
function closeMenu({ refocus = true }: { refocus?: boolean } = {}) {
requestOpen(false);
if (refocus) triggerRef.current?.focus();
}
// Outside pointer interaction closes the tree; listeners exist only while
// open and are removed as soon as it closes.
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen]);
const value: DropdownMenuContextValue = {
open: actualOpen,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement,
};
return (
<DropdownMenuContext.Provider value={value}>
<div ref={rootRef} className={cx("relative inline-flex", className)}>
{children}
</div>
</DropdownMenuContext.Provider>
);
}
export default DropdownMenu;
/* ------------------------------------------------------------------------ */
/* DropdownMenuTrigger */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
export function DropdownMenuTrigger({
children,
className,
onClick,
onKeyDown,
...rest
}: DropdownMenuTriggerProps) {
const context = useDropdownMenu("DropdownMenuTrigger");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.closeMenu();
} else {
context.requestOpen(true, "first");
}
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return (
<button
type="button"
ref={context.triggerRef}
id={context.triggerId}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.open ? context.contentId : undefined}
data-state={context.open ? "open" : "closed"}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-4 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180",
)}
/>
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuContent */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuContent({
children,
className,
onKeyDown,
...rest
}: DropdownMenuContentProps) {
const context = useDropdownMenu("DropdownMenuContent");
const level = useMenuLevel();
const contentRef = useRef<HTMLDivElement>(null);
const [resolved, setResolved] = useState<DropdownMenuPlacement>(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
// Move focus into the menu when it opens (first item, or last when the
// trigger was invoked with ArrowUp).
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
focusItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Measure against the viewport and flip the placement when the preferred
// side/alignment would overflow. Runs before paint, so the flip never
// flashes; the panel stays `invisible` until the first measurement.
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const below = window.innerHeight - t.bottom;
const above = t.top;
let side: "top" | "bottom" = context.placement.startsWith("top") ? "top" : "bottom";
if (side === "bottom" && below < c.height + margin && above > below) side = "top";
else if (side === "top" && above < c.height + margin && below > above) side = "bottom";
let align: "start" | "end" = context.placement.endsWith("end") ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(`${side}-${align}`);
setMeasured(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.placement]);
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
const current = items.indexOf(document.activeElement as HTMLElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
// Let focus leave naturally; close without stealing it back.
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
// Hovering a plain item closes the open submenu at this level.
function handlePointerOver(event: ReactMouseEvent<HTMLDivElement>) {
const node = contentRef.current;
if (!node) return;
const item = (event.target as HTMLElement).closest<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
);
if (!item || item.closest('[role="menu"]') !== node) return;
if (!item.hasAttribute("data-ds-subtrigger")) level.closeSubs();
}
if (!open) return null;
return (
<MenuLevelContext.Provider value={level}>
<div
ref={contentRef}
id={context.contentId}
role="menu"
aria-labelledby={context.triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
className={cx(MENU_CLASSES, PLACEMENT_CLASSES[resolved], !measured && "invisible", className)}
{...rest}
>
{children}
</div>
</MenuLevelContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuItem */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuItemProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
/** Informational keyboard shortcut shown at the trailing edge (aria-hidden; exposed via aria-keyshortcuts). */
shortcut?: string;
/** Destructive action styling via the semantic destructive token. */
destructive?: boolean;
/** Whether activating the item closes the menu (default true). */
closeOnSelect?: boolean;
/** Called when the item is activated, before the menu closes. Call `event.preventDefault()` to keep the menu open. */
onSelect?: (event: ReactMouseEvent<HTMLButtonElement>) => void;
children?: ReactNode;
}
export function DropdownMenuItem({
icon,
shortcut,
destructive = false,
closeOnSelect = true,
onSelect,
onClick,
onMouseEnter,
className,
children,
...rest
}: DropdownMenuItemProps) {
const context = useDropdownMenu("DropdownMenuItem");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
onSelect?.(event);
if (event.defaultPrevented) return;
if (closeOnSelect) context.closeMenu();
}
function handleMouseEnter(event: ReactMouseEvent<HTMLButtonElement>) {
onMouseEnter?.(event);
event.currentTarget.focus();
}
return (
<button
type="button"
role="menuitem"
tabIndex={-1}
aria-keyshortcuts={shortcut}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cx(ITEM_CLASSES, destructive ? ITEM_TONE_DESTRUCTIVE : ITEM_TONE_DEFAULT, className)}
{...rest}
>
{icon ? (
<span
aria-hidden="true"
className={cx(
ICON_SLOT_CLASSES,
destructive ? "text-current" : "text-[var(--ds-color-muted-foreground)]",
)}
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{shortcut ? (
<span aria-hidden="true" className={SHORTCUT_CLASSES}>
{shortcut}
</span>
) : null}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuLabel / DropdownMenuGroup / DropdownMenuSeparator */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuLabelProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuLabel({ className, children, ...rest }: DropdownMenuLabelProps) {
return (
<div className={cx(LABEL_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface DropdownMenuGroupProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuGroup({ className, children, ...rest }: DropdownMenuGroupProps) {
return (
<div role="group" className={className} {...rest}>
{children}
</div>
);
}
export function DropdownMenuSeparator({
className,
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
role="separator"
aria-orientation="horizontal"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
/>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TRIGGER_CLASSES = "inline-flex h-9 max-w-full items-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] aria-expanded:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const MENU_CLASSES = "absolute z-40 max-h-[min(20rem,calc(100vh-2rem))] min-w-[12rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const ITEM_CLASSES = "flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ITEM_TONE_DEFAULT = "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)]";
const ITEM_TONE_DESTRUCTIVE = "text-[var(--ds-color-destructive)] hover:bg-[var(--ds-color-destructive-soft)] focus:bg-[var(--ds-color-destructive-soft)]";
const ICON_SLOT_CLASSES = "inline-flex shrink-0 [&_svg]:size-4";
const SHORTCUT_CLASSES = "ml-auto shrink-0 pl-6 text-xs leading-5 text-[var(--ds-color-muted-foreground)]";
const LABEL_CLASSES = "px-2 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.05em] text-[var(--ds-color-muted-foreground)]";
const SEPARATOR_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const PLACEMENT_CLASSES = {
"bottom-start": "left-0 top-full mt-1.5",
"bottom-end": "right-0 top-full mt-1.5",
"top-start": "bottom-full left-0 mb-1.5",
"top-end": "bottom-full right-0 mb-1.5"
};
function menuItems(content) {
return Array.from(
content.querySelectorAll(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
)
).filter(
(el) => el.closest('[role="menu"]') === content && !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true"
);
}
function focusItem(items, index) {
if (items.length === 0) return;
const wrapped = (index % items.length + items.length) % items.length;
items[wrapped].focus();
}
function ChevronDown({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>;
}
const DropdownMenuContext = createContext(null);
function useDropdownMenu(component) {
const context = useContext(DropdownMenuContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <DropdownMenu>.`);
}
return context;
}
const MenuLevelContext = createContext(null);
function useMenuLevel() {
const subsRef = useRef(null);
const levelRef = useRef(null);
if (!subsRef.current) subsRef.current = /* @__PURE__ */ new Set();
if (!levelRef.current) {
const subs = subsRef.current;
levelRef.current = {
registerSub(close) {
subs.add(close);
return () => {
subs.delete(close);
};
},
closeSubs(except) {
subs.forEach((close) => {
if (close !== except) close();
});
}
};
}
return levelRef.current;
}
function DropdownMenu({
open,
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
className,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState("first");
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef(null);
const rootRef = useRef(null);
const reactId = useId();
const triggerId = `ds-menu-trigger${reactId}`;
const contentId = `ds-menu${reactId}`;
function requestOpen(next, focusTarget = "first") {
setInitialFocus(focusTarget);
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
function closeMenu({ refocus = true } = {}) {
requestOpen(false);
if (refocus) triggerRef.current?.focus();
}
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [actualOpen]);
const value = {
open: actualOpen,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement
};
return <DropdownMenuContext.Provider value={value}>
<div ref={rootRef} className={cx("relative inline-flex", className)}>
{children}
</div>
</DropdownMenuContext.Provider>;
}
function DropdownMenuTrigger({
children,
className,
onClick,
onKeyDown,
...rest
}) {
const context = useDropdownMenu("DropdownMenuTrigger");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.closeMenu();
} else {
context.requestOpen(true, "first");
}
}
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return <button
type="button"
ref={context.triggerRef}
id={context.triggerId}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.open ? context.contentId : undefined}
data-state={context.open ? "open" : "closed"}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-4 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180"
)}
/>
</button>;
}
function DropdownMenuContent({
children,
className,
onKeyDown,
...rest
}) {
const context = useDropdownMenu("DropdownMenuContent");
const level = useMenuLevel();
const contentRef = useRef(null);
const [resolved, setResolved] = useState(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
focusItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
}, [open]);
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const below = window.innerHeight - t.bottom;
const above = t.top;
let side = context.placement.startsWith("top") ? "top" : "bottom";
if (side === "bottom" && below < c.height + margin && above > below) side = "top";
else if (side === "top" && above < c.height + margin && below > above) side = "bottom";
let align = context.placement.endsWith("end") ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(`${side}-${align}`);
setMeasured(true);
}, [open, context.placement]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
const current = items.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
function handlePointerOver(event) {
const node = contentRef.current;
if (!node) return;
const item = event.target.closest(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
if (!item || item.closest('[role="menu"]') !== node) return;
if (!item.hasAttribute("data-ds-subtrigger")) level.closeSubs();
}
if (!open) return null;
return <MenuLevelContext.Provider value={level}>
<div
ref={contentRef}
id={context.contentId}
role="menu"
aria-labelledby={context.triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
className={cx(MENU_CLASSES, PLACEMENT_CLASSES[resolved], !measured && "invisible", className)}
{...rest}
>
{children}
</div>
</MenuLevelContext.Provider>;
}
function DropdownMenuItem({
icon,
shortcut,
destructive = false,
closeOnSelect = true,
onSelect,
onClick,
onMouseEnter,
className,
children,
...rest
}) {
const context = useDropdownMenu("DropdownMenuItem");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
onSelect?.(event);
if (event.defaultPrevented) return;
if (closeOnSelect) context.closeMenu();
}
function handleMouseEnter(event) {
onMouseEnter?.(event);
event.currentTarget.focus();
}
return <button
type="button"
role="menuitem"
tabIndex={-1}
aria-keyshortcuts={shortcut}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cx(ITEM_CLASSES, destructive ? ITEM_TONE_DESTRUCTIVE : ITEM_TONE_DEFAULT, className)}
{...rest}
>
{icon ? <span
aria-hidden="true"
className={cx(
ICON_SLOT_CLASSES,
destructive ? "text-current" : "text-[var(--ds-color-muted-foreground)]"
)}
>
{icon}
</span> : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{shortcut ? <span aria-hidden="true" className={SHORTCUT_CLASSES}>
{shortcut}
</span> : null}
</button>;
}
function DropdownMenuLabel({ className, children, ...rest }) {
return <div className={cx(LABEL_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DropdownMenuGroup({ className, children, ...rest }) {
return <div role="group" className={className} {...rest}>
{children}
</div>;
}
function DropdownMenuSeparator({
className,
...rest
}) {
return <div
role="separator"
aria-orientation="horizontal"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
/>;
}
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuGroup, DropdownMenuSeparator };
export default DropdownMenu; # Dropdown Menu with Icons
Menu items with meaningful leading icons in a consistent 16px aria-hidden slot — the text label still carries the accessible name, so meaning never depends on the icon alone.
## Usage
```tsx
import DropdownMenu, {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./dropdown-menu-with-icons";
import { PencilIcon, CopyIcon, ArchiveIcon } from "./icons";
<DropdownMenu>
<DropdownMenuTrigger>Document</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem icon={<PencilIcon />}>Rename document</DropdownMenuItem>
<DropdownMenuItem icon={<CopyIcon />}>Make a copy</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem icon={<ArchiveIcon />}>Archive document</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import DropdownMenu, {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./dropdown-menu-with-icons";
import { PencilIcon, CopyIcon, ArchiveIcon } from "./icons";
<DropdownMenu>
<DropdownMenuTrigger>Document</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem icon={<PencilIcon />}>Rename document</DropdownMenuItem>
<DropdownMenuItem icon={<CopyIcon />}>Make a copy</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem icon={<ArchiveIcon />}>Archive document</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
## Props
### `<DropdownMenu>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the menu requests to open or close. |
| `placement` | `"bottom-start" \| "bottom-end" \| "top-start" \| "top-end"` | `"bottom-start"` | Preferred placement; flips to stay in the viewport. |
| `className` | `string` | — | Extra classes on the relative wrapper. |
| `children` | `ReactNode` | — | `DropdownMenuTrigger` + `DropdownMenuContent`. |
### `<DropdownMenuTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible trigger label (a chevron is rendered after it). |
A real `<button type="button">` with `aria-haspopup="menu"` + `aria-expanded`; every native button attribute (`disabled`, `aria-label`, …) is forwarded.
### `<DropdownMenuContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="menu"` panel. |
| `children` | `ReactNode` | — | Items, labels, groups, and separators. |
Rendered only while open. Labelled by the trigger via `aria-labelledby`; pass `aria-label` to override.
### `<DropdownMenuItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered aria-hidden). |
| `shortcut` | `string` | — | Informational shortcut at the trailing edge (aria-hidden; exposed via `aria-keyshortcuts`). |
| `destructive` | `boolean` | `false` | Destructive styling via the semantic destructive token. |
| `disabled` | `boolean` | `false` | Native disabled: skipped by arrow keys, out of the tab order, not activatable. |
| `closeOnSelect` | `boolean` | `true` | Whether activating the item closes the menu. |
| `onSelect` | `(event) => void` | — | Called on activation before the menu closes; `event.preventDefault()` keeps the menu open. |
| `children` | `ReactNode` | — | Visible item label. |
### `<DropdownMenuLabel>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the label. |
| `children` | `ReactNode` | — | Section heading text. |
Non-interactive. Give it an `id` and point the group's `aria-labelledby` at it when labelling a `DropdownMenuGroup`.
### `<DropdownMenuGroup>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the group. |
| `children` | `ReactNode` | — | Grouped items. |
Renders `role="group"`; forward `aria-labelledby` to associate it with its `DropdownMenuLabel`.
### `<DropdownMenuSeparator>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the separator. |
A `role="separator"` horizontal rule. Not focusable, not announced as an item.
## Composition
Dropdown Menu is a compound component. Seven primitives compose the pattern:
```tsx
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Project</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
- `DropdownMenu` — the root. Owns the open state (controlled via `open` + `onOpenChange`, or uncontrolled via `defaultOpen`), the trigger/content id wiring, the placement preference, and outside-pointer closing. Renders a `relative inline-flex` wrapper the menu panel anchors to.
- `DropdownMenuTrigger` — a real `<button type="button">` with `aria-haspopup="menu"` + `aria-expanded`. Click toggles; ArrowDown opens with the first item focused, ArrowUp with the last. The trailing chevron rotates while open.
- `DropdownMenuContent` — the `role="menu"` panel, labelled by the trigger. Rendered only while open; measures itself before paint and flips placement to stay in the viewport.
- `DropdownMenuItem` — one action: a real `<button>` with `role="menuitem"`. Optional `icon`, `shortcut`, `destructive`, `disabled`, `closeOnSelect`, and `onSelect` props.
- `DropdownMenuLabel` — a non-interactive section heading (uppercase, tracked, smaller type).
- `DropdownMenuGroup` — a `role="group"` wrapper; associate it with its label via `aria-labelledby`.
- `DropdownMenuSeparator` — a `role="separator"` horizontal rule between groups.
Icons are the `icon` prop on `<DropdownMenuItem>` — a `ReactNode` rendered in a fixed 16px, aria-hidden, `currentColor` slot ahead of the label. Regular items tint the icon `--ds-color-muted-foreground`; destructive items inherit the destructive text color.
## Menu Behavior
The root `<DropdownMenu>` owns the open state. Both modes are supported:
- **Controlled** — pass `open` + `onOpenChange`; the parent owns the state.
- **Uncontrolled** — pass `defaultOpen`; the component owns the state.
Opening moves focus into the menu: the first item, or the last item when the trigger was invoked with ArrowUp. Activating an item runs its `onSelect` and then closes the menu (set `closeOnSelect={false}` or call `event.preventDefault()` in `onSelect` to keep it open). Closing — via selection, Escape, the trigger, or a pointer down outside — returns focus to the trigger, except on Tab, where focus is allowed to move forward naturally.
The panel opens relative to its trigger at the requested `placement` (`bottom-start` by default) and measures itself in a layout effect before paint: if the preferred side would leave the viewport, it flips to the other side (bottom ↔ top, start ↔ end). The panel also caps its own height (`min(20rem, 100vh - 2rem)` with internal scrolling) and width (`100vw - 1.5rem`), so menus never routinely overflow the viewport. No positioning library is involved.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the menu, focus the first item |
| `ArrowDown` (trigger) | Open the menu, focus the first item |
| `ArrowUp` (trigger) | Open the menu, focus the last item |
| `ArrowDown` / `ArrowUp` (menu) | Move focus to the next / previous enabled item, wrapping at the ends |
| `Home` / `End` (menu) | Focus the first / last enabled item |
| `Enter` / `Space` (menu) | Activate the focused item (native button behavior) |
| `Escape` | Close the menu and return focus to the trigger |
| `Tab` | Close the menu and move focus forward naturally |
The trigger and items are native `<button>` elements, so Enter/Space activation follows normal browser behavior. Disabled items use the native `disabled` attribute: they are skipped by arrow-key navigation, removed from the tab order, and cannot be activated.
## Accessibility
The structure follows the WAI-ARIA menu button pattern.
- The trigger is a native `<button>` with `aria-haspopup="menu"`, `aria-expanded`, and `aria-controls` pointing at the open panel.
- The panel is `role="menu"` labelled by its trigger (`aria-labelledby`); items are `role="menuitem"` on real `<button>` elements — no `div` click handlers.
- Focus is real DOM focus: opening moves focus into the menu, closing returns it to the trigger, and focus is never left on an unmounted element.
- Disabled items carry the native `disabled` attribute, which assistive technology announces as unavailable.
- `DropdownMenuSeparator` uses `role="separator"`; icons are `aria-hidden` decoration and shortcuts are exposed via `aria-keyshortcuts`, so accessible names stay clean.
Icons are decorative by construction: they render inside an `aria-hidden` span and the visible text remains the accessible name. Never ship an icon-only menu item — if an item's meaning is not obvious from words, improve the words.
## States
- **Trigger (idle)** — bordered surface button with a muted chevron; hover shifts to a subtle surface.
- **Trigger (open)** — `aria-expanded="true"`; keeps the hover surface and rotates the chevron 180°.
- **Item (idle)** — foreground text on the elevated menu surface.
- **Item (hover / focus)** — `--ds-color-surface-hover` background; keyboard focus additionally shows the `--ds-color-focus-ring` outline inside the item bounds.
- **Item (disabled)** — native `disabled`: 50% opacity, no pointer events, skipped by arrow keys, out of the tab order.
- **Panel** — `--ds-color-surface-elevated` with a 1px `--ds-color-border` and the restrained `--ds-shadow-md`, per the Dropdown/Popover token rules (radius-md, subtle border, body-sm type).
## Responsive Behavior
The menu panel caps its width at `100vw - 1.5rem` and its height at `min(20rem, 100vh - 2rem)` with internal scrolling, so it stays inside the viewport at every width from 375px up without shrinking the trigger. Placement flips (bottom ↔ top, start ↔ end) keep the panel attached to its trigger near viewport edges. Long item labels truncate within the panel rather than forcing horizontal page overflow; the trigger label truncates within its own `max-w-full` bounds. The trigger keeps the shared 36px (h-9) control height — a comfortable touch target — at every breakpoint.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This dropdown-menu variant uses the semantic color, radius, shadow, typography, and motion tokens.
## Notes
The icon slot is a fixed 16px (`[&_svg]:size-4`) with `shrink-0`, so labels stay aligned down the column even when some items have no icon. Pass any SVG that sizes itself to `currentColor`; no icon library is required. 567 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/dropdowns/dropdown-menu-with-icons I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/dropdowns/dropdown-menu-with-icons/ Install: npx devsnips add React/Components/Dropdowns/dropdown-menu-with-icons This is the Dropdown Menu with Icons — Menu items with meaningful leading icons in a consistent 16px aria-hidden slot — the text label still carries the accessible name, so meaning never depends on the icon alone. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/dropdowns/dropdown-menu-with-icons