컴포넌트
드롭다운 메뉴
버튼을 누르면 항목 목록이 펼쳐지는 메뉴예요.
- #arc
- #component
- #menu
- #overlay
- #actions
- #keyboard
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/dropdown-menu.json파일
components/arc/dropdown-menu/dropdown-menu.module.csscomponents/arc/dropdown-menu/dropdown-menu.tsx
npm 패키지
@radix-ui/react-dropdown-menu@^2.2.1motion@^14.2.0
함께 오는 항목
dropdown-menu.module.cssdropdown-menu.module.cssdropdown-menu.module.csscss
.trigger { display: inline-flex; max-width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .trigger:hover { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: var(--shadow-resting); } }
/* The trigger anchors the menu, so press feedback stays in color. A scaled rect would be measured on pointerdown and shift the menu. */
.trigger[data-state="open"] { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: none; }
.trigger:active { border-color: var(--border-strong); background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); box-shadow: none; }
.item:focus-visible { outline: none; }
.triggerIcon { display: inline-flex; flex: none; color: var(--text-secondary); }
.label { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; overflow-x: clip; }
.labelMeasure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }
.labelText { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chevron { flex: none; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.trigger[data-state="open"] .chevron { transform: rotate(180deg); }
/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly.
Transitions instead of keyframes, so a close that interrupts the open (or a reopen during the close) reverses from where it is. */
.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }
.menu[data-side="top"] { --menu-y: 5px; }
.menu[data-side="left"] { --menu-x: 5px; --menu-y: 0px; }
.menu[data-side="right"] { --menu-x: -5px; --menu-y: 0px; }
@starting-style {
.menu[data-state="open"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }
.menu[data-state="open"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }
}
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }
.highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-panel) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }
.highlight[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }
.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }
.icon { display: inline-flex; width: 17px; color: var(--text-secondary); }
.separator { height: 1px; margin: var(--space-1) calc(var(--space-1) * -1); background: var(--border-subtle); }
.destructive { color: var(--danger); }
.destructive .icon { color: var(--danger); }
.item[data-disabled] { opacity: .45; cursor: default; }
@keyframes menu-exit { to { --menu-exit: 1; } }
@media (prefers-reduced-transparency: reduce) { .menu { background: var(--surface-raised); backdrop-filter: none; -webkit-backdrop-filter: none; } }
@media (prefers-reduced-motion: reduce) {
.trigger, .chevron, .item, .highlight { transition: none; }
.menu, .menu[data-state="closed"] { transform: none; transition: opacity var(--duration-instant) linear; }
}
dropdown-menu.tsxdropdown-menu.tsxdropdown-menu.tsxtsx
"use client";
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, FocusEvent, ReactNode } from "react";
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./dropdown-menu.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export interface DropdownItem { label: string; onSelect?: () => void; disabled?: boolean; icon?: ReactNode; destructive?: boolean; separatorBefore?: boolean; }
export interface DropdownMenuProps { label: string; items: DropdownItem[]; icon?: ReactNode; }
type Highlight = { top: number; height: number; danger: boolean; glide: boolean };
/** A new trigger label rises in while the old one leaves, and the trigger width springs to the measured text instead of snapping. */
function TriggerLabel({ text }: { text: string }) {
const reduced = useReducedMotion();
const measure = useRef<HTMLSpanElement>(null);
const measured = useRef<string | null>(null);
const [size, setSize] = useState<{ width: number | "auto"; animate: boolean }>({ width: "auto", animate: false });
useLayoutEffect(() => {
const node = measure.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const current = node.textContent;
// Only a text change morphs; the first measure and font swaps settle instantly.
const animate = measured.current !== null && measured.current !== current;
measured.current = current;
setSize({ width: Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth), animate });
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return <motion.span className={styles.label} initial={false} animate={{ width: size.width }} transition={size.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>
<span ref={measure} className={styles.labelMeasure} aria-hidden="true">{text}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} className={styles.labelText} initial={reduced ? false : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>
</AnimatePresence>
</motion.span>;
}
export function DropdownMenu({ label, items, icon }: DropdownMenuProps) {
const reduced = useReducedMotion();
const [highlight, setHighlight] = useState<Highlight | null>(null);
const pointer = useRef(false);
const clearTimer = useRef(0);
useEffect(() => () => window.clearTimeout(clearTimer.current), []);
// Radix focuses the highlighted item (pointer or keyboard) and the content when the pointer leaves an item.
function onMenuFocus(event: FocusEvent<HTMLDivElement>) {
const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[role="menuitem"]') : null;
window.clearTimeout(clearTimer.current);
if (!item) {
// A short grace period keeps the highlight gliding across separators and item gaps.
clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);
return;
}
const next = { top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === "danger" };
const glide = pointer.current;
setHighlight(current => ({ ...next, glide: glide && current !== null }));
}
// Radix closes the menu right after an item is chosen; a choice plays only "select", so the close sound waits one microtask to see whether a choice came with it.
const chose = useRef(false);
return <DropdownPrimitive.Root onOpenChange={open => { if (open) { chose.current = false; playUiSound("open"); } else queueMicrotask(() => { if (!chose.current) playUiSound("close"); chose.current = false; }); if (open) { window.clearTimeout(clearTimer.current); setHighlight(null); } }}>
<DropdownPrimitive.Trigger className={styles.trigger} type="button">{icon && <span className={styles.triggerIcon} aria-hidden="true">{icon}</span>}<TriggerLabel text={label}/><UiIcon iconRole="chevron-down" className={styles.chevron} size={15} strokeWidth={1.8} aria-hidden="true"/></DropdownPrimitive.Trigger>
<DropdownPrimitive.Portal><DropdownPrimitive.Content className={styles.menu} sideOffset={6} align="end" collisionPadding={12} loop onFocus={onMenuFocus} onPointerMoveCapture={() => { pointer.current = true; }} onKeyDownCapture={() => { pointer.current = false; }}>
{/* One highlight glides between items for the pointer and jumps instantly for the keyboard. */}
<motion.span className={styles.highlight} data-tone={highlight?.danger ? "danger" : undefined} aria-hidden="true" initial={false} animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }} transition={{ default: highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 }, opacity: { duration: reduced ? 0 : .08 } }}/>
{items.map((item, index) => <Fragment key={item.label}>{item.separatorBefore && <DropdownPrimitive.Separator className={styles.separator}/>}<DropdownPrimitive.Item className={[styles.item, item.destructive ? styles.destructive : ""].filter(Boolean).join(" ")} data-tone={item.destructive ? "danger" : undefined} style={{ "--i": index } as CSSProperties} disabled={item.disabled} onSelect={() => { chose.current = true; playUiSound("select"); item.onSelect?.(); }}>{item.icon && <span className={styles.icon} aria-hidden="true">{item.icon}</span>}{item.label}</DropdownPrimitive.Item></Fragment>)}
</DropdownPrimitive.Content></DropdownPrimitive.Portal>
</DropdownPrimitive.Root>;
}
export default DropdownMenu;