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

className

string

지정 안 함

closeOnSelect

boolean

지정 안 함

container

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

active

boolean

지정 안 함

endAdornment

React.ReactNode

지정 안 함

selected

boolean

지정 안 함

startAdornment

React.ReactNode

지정 안 함

Menu.SubContent

하위 메뉴의 항목을 표시하는 표면이다.

Prop

Type

Default

className

string

지정 안 함

container

Element | DocumentFragment | null

지정 안 함

side

"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);
}