Menu

보조 작업의 목록을 하나의 대화형 영역으로 결합하는 컴포넌트예요. 보통 메뉴 버튼이나 드롭다운을 만들 때 사용해요.

출처: 문서

본문

사용법 (Usage)

Menu로 보조 작업 목록을 하나의 대화형 영역으로 만들 수 있어요.

import { Menu, Button, Text } from '@mantine/core';
import { GearSixIcon, MagnifyingGlassIcon, ImageIcon, ChatCircleIcon, TrashIcon, IconArrowsLeftRight } from '@phosphor-icons/react';

function Demo() {
  return (
    <Menu>
      <Menu.Target>
        <Button>Toggle menu</Button>
      </Menu.Target>

      <Menu.Dropdown>
        <Menu.Label>Application</Menu.Label>
        <Menu.Item leftSection={<GearSixIcon size={16} />}>Settings</Menu.Item>
        <Menu.Item leftSection={<ChatCircleIcon size={16} />}>Messages</Menu.Item>
        <Menu.Item leftSection={<ImageIcon size={16} />}>Gallery</Menu.Item>
        <Menu.Item leftSection={<MagnifyingGlassIcon size={16} />} rightSection={<Text size="xs">⌘K</Text>}>
          Search
        </Menu.Item>

        <Menu.Divider />

        <Menu.Label>Danger zone</Menu.Label>
        <Menu.Item leftSection={<IconArrowsLeftRight size={16} />}>Transfer my data</Menu.Item>
        <Menu.Item leftSection={<TrashIcon size={16} />} color="red">Delete my account</Menu.Item>
      </Menu.Dropdown>
    </Menu>
  );
}

서브메뉴 (Submenus)

Menu.Sub로 중첩 서브메뉴를 만들 수 있어요.

import { Button, Menu } from '@mantine/core';

function Demo() {
  return (
    <Menu>
      <Menu.Target><Button>Toggle Menu</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Item>Dashboard</Menu.Item>
        <Menu.Sub label="Products">
          <Menu.Item>All products</Menu.Item>
          <Menu.Item>Categories</Menu.Item>
          <Menu.Item>Tags</Menu.Item>
          <Menu.Item>Attributes</Menu.Item>
          <Menu.Item>Shipping classes</Menu.Item>
        </Menu.Sub>
        <Menu.Item>Customers</Menu.Item>
        <Menu.Item>Reports</Menu.Item>
        <Menu.Sub label="Orders">
          <Menu.Item>Open</Menu.Item>
          <Menu.Item>Completed</Menu.Item>
          <Menu.Item>Cancelled</Menu.Item>
        </Menu.Sub>
        <Menu.Sub label="Settings">
          <Menu.Item>Profile</Menu.Item>
          <Menu.Item>Security</Menu.Item>
          <Menu.Item>Notifications</Menu.Item>
        </Menu.Sub>
      </Menu.Dropdown>
    </Menu>
  );
}

커서가 타겟에서 드롭다운으로 이동하는 동안 서브메뉴를 열린 상태로 유지하려면 safeAreaPolygon을 사용해요. offset이 타겟과 드롭다운 사이에 더 큰 간격을 만들 때처럼 Floating UI safePolygon 옵션을 조정하려면 객체를 전달해요.

Menu.Search는 드롭다운 안에 검색 인풋을 렌더링해요. ArrowUp/ArrowDown이 항목 위로 하이라이트를 이동하는 동안 포커스는 인풋에 유지되고, Enter는 하이라이트된 항목을 트리거해요. 필터링은 사용자가 제어해요. value와 onChange를 전달하고 쿼리를 기반으로 Menu.Item children을 필터링해요. 기본적으로 메뉴 닫힘 전환이 완료된 후 검색 값은 자동으로 지워져요. 열릴 때마다 쿼리를 유지하려면 clearSearchOnClose={false}로 비활성화할 수 있어요.

import { useState } from 'react';
import { Button, Menu, Text } from '@mantine/core';

const data = [
  'Dashboard', 'Customers', 'Products', 'Orders', 'Reports',
  'Settings', 'Integrations', 'Billing', 'Team members', 'Help center',
];

function Demo() {
  const [query, setQuery] = useState('');
  const items = data.filter((item) => item.toLowerCase().includes(query.toLowerCase().trim()));

  return (
    <Menu>
      <Menu.Target><Button>Toggle menu</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Search value={query} onChange={(e) => setQuery(e.currentTarget.value)} placeholder="Search items" />
        {items.length > 0 ? (
          items.map((item) => <Menu.Item key={item}>{item}</Menu.Item>)
        ) : (
          <Menu.Item disabled>Nothing found</Menu.Item>
        )}
      </Menu.Dropdown>
    </Menu>
  );
}

같은 접근 방식이 서브메뉴에도 적용돼요. 중첩 항목이 쿼리와 일치할 때 부모를 계속 보이게 하려면, 자식 중 하나라도 쿼리와 일치하면 부모를 필터링된 트리에 포함해요. filterTree 재귀 함수로 이를 처리할 수 있어요 (자세한 예시는 원문 참고).

체크박스 항목 (Checkbox items)

Menu.CheckboxItem은 체크 표시기가 있는 메뉴 항목을 렌더링해요. 일반 Checkbox처럼 동작해요. checked/onChange로 상태를 관리하거나 defaultChecked로 비제어 값을 사용할 수 있어요. 기본적으로 체크박스 항목을 클릭해도 메뉴는 닫히지 않아요. 항목에 closeMenuOnClick을 설정하거나(또는 Menu에 closeOnItemClick={false}) 이를 재정의할 수 있어요.

import { useState } from 'react';
import { Button, Menu } from '@mantine/core';

function Demo() {
  const [columns, setColumns] = useState({ name: true, email: true, role: false, lastSeen: false });
  const setColumn = (key: keyof typeof columns) => (checked: boolean) =>
    setColumns((current) => ({ ...current, [key]: checked }));

  return (
    <Menu>
      <Menu.Target><Button>Columns</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Label>Visible columns</Menu.Label>
        <Menu.CheckboxItem checked={columns.name} onChange={setColumn('name')}>Name</Menu.CheckboxItem>
        <Menu.CheckboxItem checked={columns.email} onChange={setColumn('email')}>Email</Menu.CheckboxItem>
        <Menu.CheckboxItem checked={columns.role} onChange={setColumn('role')}>Role</Menu.CheckboxItem>
        <Menu.CheckboxItem checked={columns.lastSeen} onChange={setColumn('lastSeen')}>Last seen</Menu.CheckboxItem>
      </Menu.Dropdown>
    </Menu>
  );
}

체크박스 그룹 (Checkbox group)

Menu.CheckboxItem 컴포넌트를 Menu.CheckboxGroup으로 감싸면 단일 value: string[]/onChange 쌍(또는 비제어 defaultValue)으로 다중 선택 상태를 관리할 수 있어요. 각 항목에는 value가 필요해요. 항목을 클릭하면 그룹에서 값이 토글돼요.

import { useState } from 'react';
import { Button, Menu } from '@mantine/core';

function Demo() {
  const [columns, setColumns] = useState(['name', 'email']);

  return (
    <Menu>
      <Menu.Target><Button>Columns</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Label>Visible columns</Menu.Label>
        <Menu.CheckboxGroup value={columns} onChange={setColumns}>
          <Menu.CheckboxItem value="name">Name</Menu.CheckboxItem>
          <Menu.CheckboxItem value="email">Email</Menu.CheckboxItem>
          <Menu.CheckboxItem value="role">Role</Menu.CheckboxItem>
          <Menu.CheckboxItem value="lastSeen">Last seen</Menu.CheckboxItem>
        </Menu.CheckboxGroup>
      </Menu.Dropdown>
    </Menu>
  );
}

Menu.CheckboxItem은 자체 checked/defaultChecked/onChange prop으로 그룹 없이도 단독 사용할 수 있어요. 항목 수준의 checked와 onChange는 둘 다 있을 때 그룹을 재정의해요.

라디오 항목 (Radio items)

Menu.RadioItem은 Menu.RadioGroup 안의 단일 옵션을 나타내요. 그룹은 value/onChange(또는 비제어 defaultValue)로 선택된 값을 관리해요. 현재 선택된 항목은 표시기 점을 표시해요. 체크박스 항목처럼 라디오 항목은 기본적으로 클릭해도 메뉴가 닫히지 않아요.

import { useState } from 'react';
import { Button, Menu } from '@mantine/core';

function Demo() {
  const [sort, setSort] = useState('newest');

  return (
    <Menu>
      <Menu.Target><Button>Sort by</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Label>Order</Menu.Label>
        <Menu.RadioGroup value={sort} onChange={setSort}>
          <Menu.RadioItem value="newest">Newest first</Menu.RadioItem>
          <Menu.RadioItem value="oldest">Oldest first</Menu.RadioItem>
          <Menu.RadioItem value="popular">Most popular</Menu.RadioItem>
          <Menu.RadioItem value="commented">Most commented</Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.Dropdown>
    </Menu>
  );
}

표시기가 있는/없는 항목 라벨 정렬 (Aligning labels of items with and without indicators)

Menu의 alignItemsLabels prop으로 표시기 슬롯 공간을 어떻게 확보할지 제어할 수 있어요. Menu.Item을 Menu.CheckboxItem이나 Menu.RadioItem과 섞고 라벨이 같은 가로 위치에서 시작하길 원할 때 유용해요.

  • alignItemsLabels="with-indicators" (기본값) – Menu.CheckboxItem과 Menu.RadioItem에만 표시기 공간을 확보해요. 일반 Menu.Item은 패딩되지 않아요.
  • alignItemsLabels="all" – 모든 Menu.Item에 표시기 공간을 확보해서 일반 항목의 라벨이 체크박스·라디오 항목과 정렬돼요.
  • alignItemsLabels="none" – 현재 표시기를 보여주는 항목에만 표시기 공간을 확보해요. 체크되지 않은 체크박스·라디오 항목은 슬롯 없이 렌더링돼요 (토글할 때 레이아웃이 이동해요).

커스텀 체크 아이콘 (Custom check icon)

checkIcon prop으로 Menu.CheckboxItem과 Menu.RadioItem이 렌더링하는 기본 표시기를 바꿀 수 있어요. Menu에 checkIcon을 설정하면 드롭다운의 모든 체크박스/라디오 항목에 적용돼요. 개별 항목에 checkIcon을 설정하면 메뉴 수준 값을 재정의해요.

import { useState } from 'react';
import { CheckIcon } from '@phosphor-icons/react';
import { Button, Menu } from '@mantine/core';

function Demo() {
  const [filters, setFilters] = useState({ open: true, drafts: false, archived: false });
  const setFilter = (key: keyof typeof filters) => (checked: boolean) =>
    setFilters((current) => ({ ...current, [key]: checked }));

  return (
    <Menu checkIcon={<CheckIcon size={16} />}>
      <Menu.Target><Button>Filters</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Label>Filters</Menu.Label>
        <Menu.CheckboxItem checked={filters.open} onChange={setFilter('open')}>Open</Menu.CheckboxItem>
        <Menu.CheckboxItem checked={filters.drafts} onChange={setFilter('drafts')}>Drafts</Menu.CheckboxItem>
        <Menu.CheckboxItem checked={filters.archived} onChange={setFilter('archived')}>Archived</Menu.CheckboxItem>
      </Menu.Dropdown>
    </Menu>
  );
}

타입어헤드 네비게이션 (Type-ahead navigation)

포커스가 드롭다운 안에 있고 Menu.Search를 사용하지 않을 때, 인쇄 가능한 문자 키를 누르면 입력한 문자로 시작하는 다음 메뉴 항목으로 포커스가 이동해요. 같은 문자를 다시 누르면 그 문자로 시작하는 항목을 순환해요. 빠르게 연속(500ms 이내) 입력한 여러 문자는 전체 입력 문자열로 시작하는 항목과 일치해요. 비활성 항목은 건너뛰어요.

컨텍스트 메뉴 (Context menu)

Menu.ContextMenu를 사용하면 우클릭 시 커서 위치에 메뉴 드롭다운을 열 수 있어요. Menu.Target을 대체하고 contextmenu 이벤트에 응답해야 할 요소를 감싸요. 브라우저의 기본 컨텍스트 메뉴는 억제되고 Mantine Menu.Dropdown이 커서 위치에 배치돼요. 다시 우클릭하면 드롭다운이 새 좌표로 재배치돼요. disabled를 설정하면 브라우저 기본 컨텍스트 메뉴를 복원할 수 있어요.

import { Menu, Paper, Text } from '@mantine/core';

function Demo() {
  return (
    <Paper withBorder radius="md" p="xl" maw={400} mx="auto">
      <Menu.ContextMenu>
        <Menu.Target>
          <div>
            <Text>Right-click anywhere inside this area</Text>
            <Text size="sm" c="dimmed">The menu will open at the cursor position</Text>
          </div>
        </Menu.Target>
        <Menu.Dropdown>
          <Menu.Label>Actions</Menu.Label>
          <Menu.Item>Open</Menu.Item>
          <Menu.Item>Rename</Menu.Item>
          <Menu.Item>Duplicate</Menu.Item>
          <Menu.Divider />
          <Menu.Item color="red">Delete</Menu.Item>
        </Menu.Dropdown>
      </Menu.ContextMenu>
    </Paper>
  );
}

터치 기기 (Touch devices)

contextmenu 이벤트를 발생시키지 않는 터치 기기(특히 iOS Safari)에서는 길게 누르기로 드롭다운이 열려요. longPressDelay prop으로 드롭다운이 열리기 전 요소를 눌러야 하는 시간을 제어할 수 있고, 기본값은 500ms예요.

import { Menu } from '@mantine/core';

function Demo() {
  return (
    <Menu.ContextMenu longPressDelay={500}>
      <Menu.Target><div>Long-press me</div></Menu.Target>
      <Menu.Dropdown>
        <Menu.Item>Copy</Menu.Item>
        <Menu.Item>Paste</Menu.Item>
      </Menu.Dropdown>
    </Menu.ContextMenu>
  );
}

터치 기기에서 드롭다운 아래에 네이티브 텍스트 선택 콜아웃이 나타나는 것을 막기 위해, Menu.ContextMenu은 감싼 요소에서 텍스트 선택(user-select: none)을 비활성화해요.

제어 방식 (Controlled)

드롭다운의 열림 상태는 opened와 onChange prop으로 제어할 수 있어요.

import { useState } from 'react';
import { Menu } from '@mantine/core';

function Demo() {
  const [opened, setOpened] = useState(false);
  return <Menu opened={opened} onChange={setOpened}>{/* Menu content */}</Menu>;
}

hover 시 메뉴 표시 (Show menu on hover)

trigger="hover"로 설정하면 메뉴 타겟과 드롭다운 위에 hover할 때 드롭다운이 나타나요. closeDelay와 openDelay prop으로 열림/닫힘 지연(ms)을 제어할 수 있어요. 참고:

  • closeDelay={0}으로 설정하면 사용자가 드롭다운에 도달하기 전에 메뉴가 닫히므로, 타겟 요소와 드롭다운 사이의 공간을 없애려면 offset={0}을 설정해요.
  • trigger="hover" 메뉴는 접근 불가능해요. 키보드로 탐색하는 사용자는 사용할 수 없어요. hover와 클릭 트리거가 모두 필요하면 trigger="click-hover"를 사용해요.

모든 기기에서 접근 가능한 hover 메뉴를 만들려면 trigger="click-hover"를 사용해요. 데스크톱에서는 hover, 모바일 기기에서는 클릭으로 드롭다운이 열려요.

비활성 항목 (Disabled items)

import { Menu, Button } from '@mantine/core';
import { MagnifyingGlassIcon } from '@phosphor-icons/react';

function Demo() {
  return (
    <Menu>
      <Menu.Target><Button>Toggle menu</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Item leftSection={<MagnifyingGlassIcon size={16} />} disabled>Search</Menu.Item>
        {/* Other items ... */}
      </Menu.Dropdown>
    </Menu>
  );
}

드롭다운 위치 (Dropdown position)

드롭다운 위치는 position, offset, withArrow, arrowPosition 등의 prop으로 제어할 수 있어요.

import { Menu } from '@mantine/core';

function Demo() {
  return (
    <Menu position="bottom" offset={8} withArrow>
      {/* Menu items */}
    </Menu>
  );
}

전환 (Transitions)

Menu 드롭다운은 Transition 컴포넌트의 사전 제작 전환 중 하나로 애니메이션 처리할 수 있어요.

import { Menu } from '@mantine/core';

function Demo() {
  return (
    <Menu transitionProps={{ transition: 'rotate-left', duration: 150 }}>
      {/* Menu content */}
    </Menu>
  );
}

기본적으로 Menu.Item은 button 요소로 렌더링돼요. 변경하려면 component prop을 설정해요.

import { Menu, Button } from '@mantine/core';
import { ArrowSquareOutIcon } from '@phosphor-icons/react';

function Demo() {
  return (
    <Menu>
      <Menu.Target><Button>Toggle menu</Button></Menu.Target>
      <Menu.Dropdown>
        <Menu.Item leftSection={<ArrowSquareOutIcon size={16} />}>Mantine website</Menu.Item>
        <Menu.Item component="a" href="https://mantine.dev" target="_blank">External link</Menu.Item>
      </Menu.Dropdown>
    </Menu>
  );
}

component prop에 전달하는 컴포넌트는 루트 요소에 prop을 스프레드할 수 있어야 해요.

타겟으로 커스텀 컴포넌트 (Custom component as target)

Menu.Target 안에는 React 컴포넌트를 사용할 수 있어요. 예를 들어 아바타와 사용자 정보를 가진 버튼을 만들 수 있어요 (자세한 예시는 원문 참고).

Styles API

Menu은 Styles API를 지원해요. classNames prop으로 컴포넌트의 내부 요소에 스타일을 추가할 수 있어요.

Styles API 셀렉터:

  • dropdown – 드롭다운 요소
  • arrow – 드롭다운 화살표
  • overlay – 오버레이 요소
  • divider – Menu.Divider 루트 요소
  • label – Menu.Label 루트 요소
  • item – Menu.Item 루트 요소
  • itemLabel – Menu.Item의 라벨
  • itemSection – Menu.Item의 왼쪽·오른쪽 섹션
  • itemIndicator – Menu.CheckboxItem과 Menu.RadioItem의 표시기 슬롯
  • chevron – 서브 메뉴 치브론
  • search – Menu.Search 인풋 요소

Menu.Target은 단일 자식으로 요소나 컴포넌트를 요구해요. 문자열, 프래그먼트, 숫자, 여러 요소/컴포넌트는 지원되지 않고 오류를 던져요. 커스텀 컴포넌트는 루트 요소 ref를 얻는 prop을 제공해야 해요. 모든 Mantine 컴포넌트는 기본적으로 ref를 지원해요.

import { Menu, Button } from '@mantine/core';

function Demo() {
  return (
    <>
      <Menu.Target><button>Native button – ok</button></Menu.Target>
      {/* OK */}
      <Menu.Target><Button>Mantine component – ok</Button></Menu.Target>
      {/* String, NOT OK – will throw error */}
      {/* {`Raw string`} */}
      {/* Number, NOT OK – will throw error */}
      {/* {2} */}
      {/* Fragment, NOT OK – will throw error */}
      {/* <><span>Fragment, NOT OK, will throw error</span></> */}
      {/* Multiple nodes, NOT OK – will throw error */}
      {/* <>More that one node</> */}
    </>
  );
}

필수 ref prop (Required ref prop)

Menu.Target 안에서 렌더링되는 커스텀 컴포넌트는 ref prop을 지원해야 해요. ref를 루트 요소에 전달하지 않으면 동작하지 않아요.

// Example of code that WILL NOT WORK
import { Menu } from '@mantine/core';

// ❌ ref is not forwarded to the root element
function MyComponent() {
  return <div>My component</div>;
}

ref를 루트 요소에 전달하면 동작해요.

// Example of code that will work
import { Menu } from '@mantine/core';

// ✅ ref is forwarded to the root element
function MyComponent({ ref, ...others }: React.ComponentProps<'div'>) {
  return <div {...others} ref={ref}>My component</div>;
}

접근성 (Accessibility)

Menu는 WAI-ARIA 권장사항을 따릅니다.

  • 드롭다운 요소에는 role="menu"와 aria-labelledby="target-id" 속성이 있어요
  • 타겟 요소에는 aria-haspopup="menu", aria-expanded, aria-controls="dropdown-id" 속성이 있어요
  • 메뉴 항목에는 role="menuitem" 속성이 있어요

드롭다운이 닫혀 있는 동안 aria-controls 속성은 undefined가 돼요.

지원 타겟 요소 (Supported target elements)

trigger="click"(기본값)인 비제어 Menu는 button 요소나 그것을 렌더링하는 컴포넌트(Button, ActionIcon 등)와 함께 쓸 때만 접근 가능해요. 다른 요소는 Space와 Enter 키 입력을 지원하지 않아요.

hover 메뉴 (Hover menu)

trigger="hover" 메뉴는 접근 불가능해요. 키보드로 접근할 수 없어요. 접근성이 중요하지 않을 때만 사용해요. hover와 클릭 트리거가 모두 필요하면 trigger="click-hover"를 사용해요.

네비게이션 (Navigation)

네비게이션을 만들기 위해 Menu를 사용한다면, 아래 데모의 옵션을 사용해 WAI-ARIA 네비게이션 권장사항을 따를 수 있어요.

키보드 상호작용 (Keyboard interactions)

Key Description Condition
Escape 드롭다운 닫기 드롭다운 내부 포커스
Space/Enter 드롭다운 열기/닫기 타겟 요소 포커스
ArrowUp 이전 메뉴 항목으로 포커스 이동 드롭다운 내부 포커스
ArrowDown 다음 메뉴 항목으로 포커스 이동 드롭다운 내부 포커스
Home 첫 메뉴 항목으로 포커스 이동 드롭다운 내부 포커스
End 마지막 메뉴 항목으로 포커스 이동 드롭다운 내부 포커스
ArrowUp/ArrowDown 인풋을 떠나지 않고 이전/다음 메뉴 항목으로 하이라이트 이동 Menu.Search 포커스
Enter 하이라이트된 항목 트리거 Menu.Search 포커스
Printable character 입력한 문자로 시작하는 다음 항목으로 포커스 이동. 같은 문자를 다시 누르면 일치 항목을 순환. 500ms 내 연속 입력한 여러 문자는 입력 문자열로 시작하는 항목과 일치 드롭다운 내부 포커스, Menu.Search 없음

Tab과 Shift + Tab도 지원해야 한다면 menuItemTabIndex={0}을 설정해요.

더 알아보기 (Learn more)