Menu
Data Input
화면 위에 겹쳐 띄우는 명령 목록.
Usage
현재 화면을 유지한 채 관련된 짧은 명령 목록을 트리거 주변에 제공할 때 사용한다.
import
import
import { Menu } from '@mildang/design-system/Menu';Menu 하나만 가져오면 Menu.Root · Menu.Trigger · Menu.Content · Menu.Item · Menu.CheckboxItem · Menu.Label · Menu.Separator · Menu.Group · Menu.Sub · Menu.SubTrigger · Menu.SubContent 를 그 아래에서 쓸 수 있다.
예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
Anatomy
tsx
import { Menu } from '@mildang/design-system/Menu';
export default function Example() {
return (
<Menu>
<Menu.Trigger />
<Menu.Content>
<Menu.Label />
<Menu.Group>
<Menu.Item />
<Menu.CheckboxItem />
</Menu.Group>
<Menu.Separator />
<Menu.Sub>
<Menu.SubTrigger />
<Menu.SubContent />
</Menu.Sub>
</Menu.Content>
</Menu>
);
}부품
필수 여부
반복
위치
Menu.Root
Trigger와 Content를 직접 감싼다.
Menu.Trigger
Root 안에서 Content와 형제 관계로 배치한다.
Menu.Content
Root 안에 배치한다.
Menu.Label
여러 개 가능
Content 또는 Group 앞에 배치한다.
Menu.Group
여러 개 가능
Content 안에 배치한다.
Menu.Item
일반 명령을 제공할 때 하나 이상 배치한다.
여러 개 가능
Content 또는 Group 안에 배치한다.
Menu.CheckboxItem
여러 개 가능
Content 또는 Group 안에 배치한다.
Menu.Separator
여러 개 가능
Content 또는 Group 사이에 배치한다.
Menu.Sub
여러 개 가능
Content 또는 Group 안에 배치한다.
Menu.SubTrigger
하위 메뉴를 제공할 때 SubContent와 함께 배치한다.
Sub 안에 배치한다.
Menu.SubContent
SubTrigger가 여는 하위 메뉴가 있을 때 추가한다.
Sub 안에서 SubTrigger와 함께 배치한다.
- Root 안에 Trigger와 Content를 배치한다.
- Content 안의 명령은 Item 또는 CheckboxItem으로 표현하고, Label·Group·Separator로 의미를 묶는다.
- 하위 메뉴는 Sub 안에 SubTrigger와 SubContent를 함께 배치한다.
API Reference
Menu.Trigger
메뉴를 여는 버튼 또는 트리거다.
공개 Props 없음
Menu.Content
메뉴 항목을 담아 포털로 표시하는 표면이다.
Prop
Type
Default
string
지정 안 함
boolean
지정 안 함
Element | DocumentFragment | null
지정 안 함
Menu.Label
메뉴 항목 그룹을 설명하는 비 interactive 레이블이다.
공개 Props 없음
Menu.Group
관련된 메뉴 항목을 하나의 그룹으로 묶는다.
공개 Props 없음
Menu.Item
사용자가 실행할 수 있는 일반 명령 항목이다.
공개 Props 없음
Menu.CheckboxItem
체크 상태를 유지하는 토글 명령 항목이다.
공개 Props 없음
Menu.Separator
서로 다른 명령 그룹을 시각적으로 나누는 구분선이다.
공개 Props 없음
Menu.Sub
하위 메뉴의 열림 상태와 컨텍스트를 관리한다.
공개 Props 없음
Menu.SubTrigger
하위 메뉴를 여는 항목이다.
Prop
Type
Default
boolean
지정 안 함
React.ReactNode
지정 안 함
boolean
지정 안 함
React.ReactNode
지정 안 함
Menu.SubContent
하위 메뉴의 항목을 표시하는 표면이다.
Prop
Type
Default
string
지정 안 함
Element | DocumentFragment | null
지정 안 함
"bottom" | "left" | "right" | "top"
지정 안 함
사용 가이드
권장
- 현재 화면을 유지한 채 짧은 명령 목록을 트리거 가까이에 제공한다.
- 항목 라벨은 동사로 시작하는 2~3단어로 쓰고, 아이콘·체크·단축키는 보조 정보로 배치한다.
- 항목이 많으면 그룹·구분선·검색·최대 높이 스크롤을 조합한다.
지양
- 폼 입력이나 수십 개 일괄 편집처럼 복잡한 작업을 메뉴에 넣지 않는다.
- 핵심 CTA를 메뉴 안에 숨기거나 액션과 내비게이션을 구분 없이 섞지 않는다.
- 모바일에서 2단 이상 서브메뉴와 아이콘만 있는 항목을 사용하지 않는다.
예제
기본 사용
버튼으로 여는 실제 사용 형태다. 열린 메뉴의 모습은 open 예제에서 고정해 볼 수 있다.
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import * as React from 'react';
type StoryArgs = React.ComponentProps<typeof Menu.Root> & {
onItemClick?: (label: string) => void;
};
const STORY_DEFAULT_ARGS = { ...({}), ...({}) } as StoryArgs;
const MenuDemoExampleRender = (args: StoryArgs) => {
const { onItemClick: _onItemClick, ...rest } = args;
void _onItemClick;
return (
<Menu.Root {...rest}>
<Menu.Trigger asChild>
<Button>Open menu</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>Actions</Menu.Label>
<Menu.Item description="Description">Copy</Menu.Item>
<Menu.Item>Cut</Menu.Item>
<Menu.Item description="Description">Paste</Menu.Item>
<Menu.Separator />
<Menu.Sub>
<Menu.SubTrigger>More</Menu.SubTrigger>
<Menu.SubContent>
<Menu.Item>About</Menu.Item>
<Menu.Item>Docs</Menu.Item>
</Menu.SubContent>
</Menu.Sub>
</Menu.Content>
</Menu.Root>
);
};
export default function MenuDemoExample(props: Partial<StoryArgs>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as StoryArgs;
return MenuDemoExampleRender(mergedProps);
}
열린 상태
열린 메뉴를 상자 안에 고정해 보여준다. container·contain: paint·modal={false} 세 가지가 함께 있어야 상자 밖으로 새지 않는다.
코드
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import { Chip } from '@mildang/design-system/Chip';
import Star from '@mildang/icons/react/star-fill';
import * as React from 'react';
type ChipColor = NonNullable<React.ComponentProps<typeof Chip>['color']>;
/** Chip 의 color 유니온이 바뀌면 여기서 바로 타입 에러가 나도록 satisfies 로 묶는다. */
const CHIP_COLORS = [
'brand',
'neutral',
'blue',
'green',
'orange',
'red',
] as const satisfies readonly ChipColor[];
const MenuChipContentExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Chip options</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>태그</Menu.Label>
{CHIP_COLORS.map((color) => (
<Menu.Item key={color}>
<Chip label={color} color={color} type="text" size="sm" />
</Menu.Item>
))}
<Menu.Separator />
<Menu.Label>아이콘 조합</Menu.Label>
<Menu.Item>
<Chip label="with icon" color="green" startIcon={<Star />} type="text" size="sm" />
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuChipContentExample;
TextOnly
import * as React from 'react';
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
const MenuTextOnlyExample = () => {
const Example: React.FC = () => {
const [a, setA] = React.useState(true);
const [b, setB] = React.useState(false);
return (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Checkboxes & Keep Open</Button>
</Menu.Trigger>
<Menu.Content closeOnSelect={false}>
<Menu.CheckboxItem checked={a} onCheckedChange={setA}>
Keep Open: A
</Menu.CheckboxItem>
<Menu.CheckboxItem checked={b} onCheckedChange={setB}>
Keep Open: B
</Menu.CheckboxItem>
</Menu.Content>
</Menu.Root>
);
};
return <Example />;
};
export default MenuTextOnlyExample;
LeftIcon
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import Star from '@mildang/icons/react/star-fill';
const MenuLeftIconExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>With adornments</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>With icons</Menu.Label>
<Menu.Item startAdornment={<Star />}>Left icon</Menu.Item>
<Menu.Item startAdornment={<Star />} disabled>
Both sides
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuLeftIconExample;
RightIcon
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import ChevronRight from '@mildang/icons/react/chevron-right';
const MenuRightIconExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>With right icons</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Item endAdornment={<ChevronRight />}>Option 1</Menu.Item>
<Menu.Item endAdornment={<ChevronRight />}>Option 2</Menu.Item>
<Menu.Item endAdornment={<ChevronRight />}>Option 3</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuRightIconExample;
LeftRightIcon
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import Star from '@mildang/icons/react/star-fill';
import ChevronRight from '@mildang/icons/react/chevron-right';
const MenuLeftRightIconExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>With left and right icons</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Item startAdornment={<Star />} endAdornment={<ChevronRight />}>
Option 1
</Menu.Item>
<Menu.Item startAdornment={<Star />} endAdornment={<ChevronRight />}>
Option 2
</Menu.Item>
<Menu.Item startAdornment={<Star />} endAdornment={<ChevronRight />}>
Option 3
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuLeftRightIconExample;
WithAvatarCaption
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import { default as Avatar } from '@mildang/design-system/Avatar';
const MenuWithAvatarCaptionExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>With avatar caption</Button>
</Menu.Trigger>
<Menu.Content maxWidth="380px">
<Menu.Item
startAdornment={<Avatar size="xs" initial="김철수" />}
caption={['개발팀', 'Manager', '온라인']}
>
김철수
</Menu.Item>
<Menu.Item
startAdornment={<Avatar size="xs" initial="홍길동" />}
caption={['디자인팀', 'Designer', '오프라인']}
>
홍길동
</Menu.Item>
<Menu.Item startAdornment={<Avatar size="xs" initial="박지민" />} caption={['운영팀', 'Ops']} active>
박지민
</Menu.Item>
<Menu.Item startAdornment={<Avatar size="xs" initial="이영희" />} caption={['기획팀', 'PM']} selected>
이영희
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuWithAvatarCaptionExample;
DescriptionPosition
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import ChevronRight from '@mildang/icons/react/chevron-right';
const MenuDescriptionPositionExample = () => (
// modal(기본값)이면 한 메뉴를 열 때 나머지 영역이 aria-hidden 처리되어 두 번째 트리거를 찾을 수 없다.
// 두 예시를 나란히 열어 비교하는 스토리이므로 non-modal로 둔다.
<div style={{ display: 'flex', gap: 16 }}>
<Menu.Root modal={false}>
<Menu.Trigger asChild>
<Button>Bottom</Button>
</Menu.Trigger>
<Menu.Content maxWidth="280px">
<Menu.Item description="Description" endAdornment={<ChevronRight />}>
Option Title
</Menu.Item>
<Menu.Item
description="The two lines look like this: Do not use more than two lines."
selected
endAdornment={<ChevronRight />}
>
Option Title
</Menu.Item>
<Menu.Item description="Description" endAdornment={<ChevronRight />}>
Option Title
</Menu.Item>
</Menu.Content>
</Menu.Root>
<Menu.Root modal={false}>
<Menu.Trigger asChild>
<Button>Right</Button>
</Menu.Trigger>
<Menu.Content maxWidth="280px">
<Menu.Item
description="The two lines look like this: Do not use more than two lines."
descriptionPosition="right"
endAdornment={<ChevronRight />}
>
Copy
</Menu.Item>
<Menu.Item
description="Description"
descriptionPosition="right"
selected
endAdornment={<ChevronRight />}
>
Cut
</Menu.Item>
<Menu.Item description="Description" descriptionPosition="right" endAdornment={<ChevronRight />}>
Paste
</Menu.Item>
</Menu.Content>
</Menu.Root>
</div>
);
export default MenuDescriptionPositionExample;
ChipMultiSelect
import * as React from 'react';
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import { Chip } from '@mildang/design-system/Chip';
type ChipColor = NonNullable<React.ComponentProps<typeof Chip>['color']>;
const MULTI_SELECT_CHIPS = [
{ key: 'success', color: 'green', label: 'Success' },
{ key: 'info', color: 'blue', label: 'Info' },
{ key: 'warning', color: 'orange', label: 'Warning' },
{ key: 'error', color: 'red', label: 'Error' },
] as const satisfies readonly { key: string; color: ChipColor; label: string }[];
const MenuChipMultiSelectExample = () => {
const Example: React.FC = () => {
const [selected, setSelected] = React.useState<Record<string, boolean>>({
success: true,
info: true,
});
const toggle = (key: string) => (v: unknown) => {
setSelected((prev) => ({ ...prev, [key]: Boolean(v) }));
};
return (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Chip multi-select</Button>
</Menu.Trigger>
<Menu.Content closeOnSelect={false}>
{MULTI_SELECT_CHIPS.map(({ key, color, label }) => (
<Menu.CheckboxItem key={key} checked={selected[key]} onCheckedChange={toggle(key)}>
<Chip label={label} color={color} type="text" size="sm" />
</Menu.CheckboxItem>
))}
</Menu.Content>
</Menu.Root>
);
};
return <Example />;
};
export default MenuChipMultiSelectExample;
SelectedWithEndAdornment
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import ChevronRight from '@mildang/icons/react/chevron-right';
import Star from '@mildang/icons/react/star-fill';
const MenuSelectedWithEndAdornmentExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Selected + endAdornment</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>icon_right</Menu.Label>
<Menu.Item endAdornment={<ChevronRight />}>Option</Menu.Item>
<Menu.Item endAdornment={<ChevronRight />} selected>
Selected
</Menu.Item>
<Menu.Separator />
<Menu.Label>icon_both</Menu.Label>
<Menu.Item startAdornment={<Star />} endAdornment={<ChevronRight />}>
Option
</Menu.Item>
<Menu.Item startAdornment={<Star />} endAdornment={<ChevronRight />} selected>
Selected
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuSelectedWithEndAdornmentExample;
NestedMenu
import * as React from 'react';
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
const MenuNestedMenuExample = () => {
const Example = () => {
const [deepChecked, setDeepChecked] = React.useState<boolean>(true);
const [deepChecked2, setDeepChecked2] = React.useState<boolean>(false);
const hasActive = deepChecked || deepChecked2;
return (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Nested Menu</Button>
</Menu.Trigger>
<Menu.Content closeOnSelect={false}>
<Menu.CheckboxItem checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
<Menu.Sub>
<Menu.SubTrigger active={hasActive}>Parent</Menu.SubTrigger>
<Menu.SubContent>
<Menu.CheckboxItem checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
<Menu.Sub>
<Menu.SubTrigger active={hasActive}>Child</Menu.SubTrigger>
<Menu.SubContent>
<Menu.CheckboxItem
checked={deepChecked}
onCheckedChange={(v: unknown) => setDeepChecked(Boolean(v))}
>
Option
</Menu.CheckboxItem>
<Menu.CheckboxItem
checked={deepChecked2}
onCheckedChange={(v: unknown) => setDeepChecked2(Boolean(v))}
>
Option
</Menu.CheckboxItem>
<Menu.CheckboxItem disabled checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
</Menu.SubContent>
</Menu.Sub>
<Menu.CheckboxItem checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
<Menu.CheckboxItem disabled checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
</Menu.SubContent>
</Menu.Sub>
<Menu.CheckboxItem checked={false} onCheckedChange={() => {}}>
Option
</Menu.CheckboxItem>
</Menu.Content>
</Menu.Root>
);
};
return <Example />;
};
export default MenuNestedMenuExample;
WithSeparator
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
const MenuWithSeparatorExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>영역 분리 예시</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>편집</Menu.Label>
<Menu.Item>복사</Menu.Item>
<Menu.Item>잘라내기</Menu.Item>
<Menu.Item>붙여넣기</Menu.Item>
<Menu.Separator />
<Menu.Label>이동</Menu.Label>
<Menu.Item>다음</Menu.Item>
<Menu.Item>이전</Menu.Item>
</Menu.Content>
</Menu.Root>
);
export default MenuWithSeparatorExample;
Scrollable
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
const MenuScrollableExample = () => (
<Menu.Root>
<Menu.Trigger asChild>
<Button>Scrollable menu</Button>
</Menu.Trigger>
<Menu.Content maxHeight="240px" overflowY="auto">
<Menu.Label>Scrollable</Menu.Label>
{Array.from({ length: 18 }).map((_, i) => (
<Menu.Item key={i}>Option {i + 1}</Menu.Item>
))}
</Menu.Content>
</Menu.Root>
);
export default MenuScrollableExample;
WithOnClick
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
import * as React from 'react';
type StoryArgs = React.ComponentProps<typeof Menu.Root> & {
onItemClick?: (label: string) => void;
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
onItemClick: () => undefined,
}) } as StoryArgs;
const MenuWithOnClickExampleRender = (args: StoryArgs) => {
const { onItemClick, ...rest } = args;
return (
<div>
<Menu.Root {...rest}>
<Menu.Trigger asChild>
<Button>Click items</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Item onSelect={() => onItemClick?.('Copy')}>Copy</Menu.Item>
<Menu.Item onSelect={() => onItemClick?.('Cut')}>Cut</Menu.Item>
<Menu.Item onSelect={() => onItemClick?.('Paste')}>Paste</Menu.Item>
</Menu.Content>
</Menu.Root>
</div>
);
};
export default function MenuWithOnClickExample(props: Partial<StoryArgs>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as StoryArgs;
return MenuWithOnClickExampleRender(mergedProps);
}
KeyboardNavigation
import * as React from 'react';
import { Menu } from '@mildang/design-system/Menu';
import { Button } from '@mildang/design-system/Button';
type StoryArgs = React.ComponentProps<typeof Menu.Root> & {
onItemClick?: (label: string) => void;
};
const STORY_DEFAULT_ARGS = { ...({}), ...({
onItemClick: () => undefined,
}) } as StoryArgs;
const MenuKeyboardNavigationExampleRender = (args: StoryArgs) => {
const Example = () => {
const [selectedItem, setSelectedItem] = React.useState<string>('');
return (
<div>
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
키보드로 메뉴를 열고 방향키로 이동한 후 엔터로 선택해보세요. 선택된 항목:{' '}
<strong>{selectedItem || '없음'}</strong>
</p>
<Menu.Root>
<Menu.Trigger asChild>
<Button>키보드 네비게이션 테스트</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Label>키보드 접근성 테스트</Menu.Label>
<Menu.Item
onSelect={() => {
setSelectedItem('복사');
args.onItemClick?.('복사');
}}
>
복사 (Ctrl+C)
</Menu.Item>
<Menu.Item
onSelect={() => {
setSelectedItem('잘라내기');
args.onItemClick?.('잘라내기');
}}
>
잘라내기 (Ctrl+X)
</Menu.Item>
<Menu.Item
onSelect={() => {
setSelectedItem('붙여넣기');
args.onItemClick?.('붙여넣기');
}}
>
붙여넣기 (Ctrl+V)
</Menu.Item>
<Menu.Separator />
<Menu.Item
onSelect={() => {
setSelectedItem('실행 취소');
args.onItemClick?.('실행 취소');
}}
>
실행 취소 (Ctrl+Z)
</Menu.Item>
<Menu.Item
onSelect={() => {
setSelectedItem('다시 실행');
args.onItemClick?.('다시 실행');
}}
>
다시 실행 (Ctrl+Y)
</Menu.Item>
</Menu.Content>
</Menu.Root>
</div>
);
};
return <Example />;
};
export default function MenuKeyboardNavigationExample(props: Partial<StoryArgs>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as StoryArgs;
return MenuKeyboardNavigationExampleRender(mergedProps);
}