컴포넌트 패턴
메뉴 버튼
버튼을 누르면 실행할 수 있는 명령 목록(role="menu")이 열리는 패턴입니다. 데스크톱 앱의 메뉴처럼 화살표 키로 항목을 고릅니다. 사이트 내비게이션의 드롭다운은 메뉴가 아니라 디스클로저로 만듭니다.
언제 쓰나요
메뉴 버튼(menu button)은 누르면 메뉴를 여는 버튼입니다. 여기서 메뉴(menu)란 데스크톱 프로그램의 “편집” 메뉴처럼 실행할 명령이나 고를 옵션의 목록을 말합니다. 표의 한 행에 대한 “수정·복제·삭제”, 문서의 “내보내기” 형식 선택, 텍스트 편집기의 서식 명령처럼 앱 성격의 화면에서 씁니다.
사이트 내비게이션은 메뉴가 아닙니다
헤더의 “고객센터 ▾”처럼 링크 목록을 펼치는 드롭다운에 role="menu"를 쓰는 것은 대표적인 오용입니다. menu 역할을 만나면 화면낭독기는 앱 조작 모드로 바뀌고, 사용자는 화살표 키·Esc·문자 탐색 같은 앱 메뉴의 키보드 동작을 기대합니다. 대부분의 웹 사이트 사용자는 링크 목록을 Tab으로 훑어보고 싶어 하며, 링크는 “링크”로 안내되어야 새 페이지로 이동한다는 사실을 압니다.
사이트 내비게이션 드롭다운은 <nav> 안에서 디스클로저(버튼 + aria-expanded)로 하위 링크 목록을 펼치는 방식이 알맞습니다. APG의 “Disclosure Navigation Menu” 예제가 이 방식입니다.
| 구분 | 메뉴 버튼 (role="menu") | 내비게이션 디스클로저 |
|---|---|---|
| 목록 내용 | 명령·옵션 (현재 화면에서 무언가를 실행) | 다른 페이지로 가는 링크 |
| 버튼 속성 | aria-haspopup="menu" + aria-expanded | aria-expanded (+ aria-controls) |
| 항목 역할 | menuitem 등 | 일반 <a href> (현재 페이지는 aria-current="page") |
| 항목 간 이동 | ↑ ↓ (항목은 Tab 순서에 없음) | Tab (화살표는 선택 사항) |
| 열면 포커스 | 첫(또는 마지막) 메뉴 항목으로 이동 | 버튼에 그대로 |
그 밖의 대안도 확인하세요. 하나의 값을 고르는 것이 목적이면 <select>나 리스트박스, 펼쳐진 내용이 폼·설명 등 자유로운 내용이면 디스클로저나 비모달 대화상자가 맞습니다.
구조
button aria-haspopup="menu" aria-expanded="true" aria-controls="m1" ← 여는 버튼
ul#m1[role=menu] aria-labelledby="버튼 id" ← 명령 목록
├─ li[role=menuitem] tabindex="-1" 잘라내기
├─ li[role=menuitem] tabindex="-1" 복사
├─ li[role=menuitem] tabindex="-1" aria-disabled="true" 되돌리기
└─ li[role=menuitem] tabindex="-1" 삭제| 요소 | 역할 | 속성 |
|---|---|---|
| 메뉴 버튼 | button | aria-haspopup (menu 또는 true), aria-expanded, (선택) aria-controls |
| 메뉴 | menu | aria-labelledby → 메뉴 버튼, 또는 aria-label |
| 메뉴 항목 | menuitem, menuitemcheckbox, menuitemradio | tabindex="-1", 필요하면 aria-disabled, 체크형은 aria-checked |
키보드 동작
메뉴 버튼에 포커스가 있을 때
| 키 | 동작 |
|---|---|
| Enter | 메뉴를 열고 첫 메뉴 항목으로 포커스를 옮깁니다. |
| Space | 메뉴를 열고 첫 메뉴 항목으로 포커스를 옮깁니다. |
| ↓ (선택) | 메뉴를 열고 첫 메뉴 항목으로 포커스를 옮깁니다. |
| ↑ (선택) | 메뉴를 열고 마지막 메뉴 항목으로 포커스를 옮깁니다. |
메뉴가 열려 있을 때
| 키 | 동작 |
|---|---|
| ↓ | 다음 항목으로 이동합니다. 마지막 항목에서는 첫 항목으로 돌아갑니다(순환은 선택 사항). |
| ↑ | 이전 항목으로 이동합니다. 첫 항목에서는 마지막 항목으로 갑니다. |
| Home | 첫 항목으로 이동합니다. |
| End | 마지막 항목으로 이동합니다. |
| Enter | 항목을 실행하고 메뉴를 닫은 뒤, 메뉴 버튼으로 포커스를 돌려줍니다. |
| Space (선택) | 일반 항목이면 Enter와 같습니다. menuitemcheckbox·menuitemradio이면 메뉴를 닫지 않고 상태만 바꿉니다. |
| Esc | 메뉴를 닫고 메뉴 버튼으로 포커스를 돌려줍니다. |
| Tab / Shift + Tab | 메뉴를 닫고 포커스를 메뉴 밖으로 옮깁니다. 메뉴 항목 사이를 이동하지 않습니다. |
| 문자 키 (선택) | 그 글자로 시작하는 다음 항목으로 이동합니다. |
한글 레이블과 문자 탐색
문자 탐색(type-ahead)은 키 하나가 글자 하나로 바로 들어오는 영문·숫자 레이블에서 잘 동작합니다. 한글은 입력기(IME)가 자모를 조합하는 방식이라 편집할 수 없는 메뉴 항목에서 기대대로 동작하지 않을 수 있습니다. 문자 탐색은 선택 사항이므로, 화살표·Home·End를 확실히 지원하는 것이 우선입니다. 아래 데모에서는 P를 누르면 “PDF로 내보내기”로 이동합니다.
필요한 ARIA
aria-haspopup="menu"- 이 버튼이 메뉴를 연다는 사실을 알립니다.
true는menu와 같은 뜻입니다. 화면낭독기는 “메뉴 버튼”, “하위 메뉴 있음” 등으로 안내합니다. 메뉴가 아닌 것(링크 목록, 일반 패널)을 열 때는 쓰지 않습니다. aria-expanded- 메뉴가 열려 있으면
true, 닫혀 있으면false입니다. aria-controls(선택)- 버튼이 여는 메뉴의
id를 가리킵니다. menu·aria-labelledby- 메뉴 컨테이너입니다. 메뉴 버튼으로 이름을 받아 “편집, 메뉴”처럼 안내됩니다.
menu안에는 메뉴 항목(또는group,separator)만 둡니다. menuitem·menuitemcheckbox·menuitemradio- 메뉴의 각 항목입니다. 켜고 끄는 항목은
menuitemcheckbox, 여러 개 중 하나를 고르는 항목은menuitemradio로 만들고aria-checked로 상태를 알립니다. 항목은tabindex="-1"로 Tab 순서에서 빼고, 스크립트로 포커스를 옮깁니다(또는 메뉴에aria-activedescendant). aria-disabled- 지금 실행할 수 없는 항목입니다. 숨기지 않고 포커스는 받게 두어 “이런 명령이 있지만 지금은 쓸 수 없다”는 사실을 전달합니다. 실행은 스크립트에서 막습니다.
직접 해 보기
명령 메뉴 (올바른 메뉴 버튼)
버튼에서 Enter, Space, ↓, ↑를 눌러 메뉴를 열고, ↑ ↓ Home End P로 이동한 뒤 Enter로 실행하거나 Esc·Tab으로 닫아 보세요. “되돌리기”는 비활성 항목입니다.
실시간 코드
내비게이션: 메뉴 역할과 디스클로저 비교
잘못된 구현
실시간 코드
링크 목록에 role="menu"·menuitem을 붙였지만 메뉴의 키보드 동작은 없습니다. 화면낭독기는 “메뉴”로 안내해 화살표 키를 기대하게 만들고, 실제로는 Tab으로만 움직입니다. 링크도 메뉴 항목으로 안내되어 이동한다는 사실이 흐려집니다.
올바른 구현
실시간 코드
<nav> 안의 버튼이 aria-expanded로 링크 목록을 펼칩니다. 링크는 평범한 링크로 Tab으로 이동하고, Esc를 누르거나 포커스가 밖으로 나가면 닫힙니다.
전체 코드
명령 메뉴
<button type="button" id="edit-btn"
aria-haspopup="menu" aria-expanded="false" aria-controls="edit-menu">
편집 <span aria-hidden="true">▾</span>
</button>
<ul role="menu" id="edit-menu" aria-labelledby="edit-btn" hidden>
<li role="menuitem" tabindex="-1">잘라내기</li>
<li role="menuitem" tabindex="-1">복사</li>
<li role="menuitem" tabindex="-1">붙여넣기</li>
<li role="menuitem" tabindex="-1" aria-disabled="true">되돌리기</li>
<li role="menuitem" tabindex="-1">삭제</li>
</ul>
<script>
initMenuButton(
document.getElementById('edit-btn'),
document.getElementById('edit-menu'),
function (item) { /* item.textContent에 맞는 명령 실행 */ }
);
</script>// 메뉴 버튼: 버튼 + role="menu" (명령 목록)
function initMenuButton(button, menu, onSelect) {
var items = Array.prototype.slice.call(menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'));
function isOpen() { return button.getAttribute('aria-expanded') === 'true'; }
function focusItem(i) { items[(i + items.length) % items.length].focus(); }
function open(where) {
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
focusItem(where === 'last' ? items.length - 1 : 0);
}
function close(returnFocus) {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
if (returnFocus) button.focus();
}
function activate(item) {
if (item.getAttribute('aria-disabled') === 'true') return; // 비활성 항목은 실행하지 않음
close(true);
if (onSelect) onSelect(item);
}
// 마우스 클릭, Enter, Space: 열고 첫 항목으로 (열려 있으면 닫기)
button.addEventListener('click', function () {
if (isOpen()) close(true); else open('first');
});
button.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); open('first'); }
else if (e.key === 'ArrowUp') { e.preventDefault(); open('last'); }
});
menu.addEventListener('keydown', function (e) {
var i = items.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(i + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(i - 1); }
else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
else if (e.key === 'End') { e.preventDefault(); focusItem(items.length - 1); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (i > -1) activate(items[i]); }
else if (e.key === 'Tab') {
if (e.shiftKey) { e.preventDefault(); close(true); } // 버튼으로
else { button.focus(); close(false); } // 기본 Tab 동작으로 버튼 다음 요소로
} else if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
// (선택) 문자 탐색: 그 글자로 시작하는 다음 항목으로
var ch = e.key.toLowerCase();
for (var n = 1; n <= items.length; n++) {
var cand = items[(i + n) % items.length];
if (cand.textContent.trim().toLowerCase().charAt(0) === ch) { cand.focus(); break; }
}
}
});
menu.addEventListener('click', function (e) {
var item = e.target.closest('[role^="menuitem"]');
if (item) activate(item);
});
// 바깥을 누르면 닫기
document.addEventListener('mousedown', function (e) {
if (isOpen() && !menu.contains(e.target) && !button.contains(e.target)) close(false);
});
}내비게이션 디스클로저
<nav aria-label="주 메뉴">
<ul>
<li>
<button type="button" aria-expanded="false" aria-controls="sub-help">고객센터</button>
<ul id="sub-help" hidden>
<li><a href="/notice">공지사항</a></li>
<li><a href="/faq" aria-current="page">자주 묻는 질문</a></li>
<li><a href="/inquiry">1:1 문의</a></li>
</ul>
</li>
</ul>
</nav>// 내비게이션 드롭다운은 메뉴가 아니라 디스클로저로 만듭니다.
function initDisclosureNav(nav) {
var buttons = Array.prototype.slice.call(nav.querySelectorAll('button[aria-expanded]'));
function set(btn, open) {
btn.setAttribute('aria-expanded', String(open));
document.getElementById(btn.getAttribute('aria-controls')).hidden = !open;
}
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') !== 'true';
buttons.forEach(function (b) { if (b !== btn) set(b, false); }); // 하나만 열기
set(btn, open);
});
});
// Esc: 열린 목록을 닫고 그 버튼으로
nav.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
buttons.forEach(function (b) {
if (b.getAttribute('aria-expanded') === 'true') { set(b, false); b.focus(); }
});
});
// 포커스가 내비게이션을 벗어나면 닫기
nav.addEventListener('focusout', function (e) {
if (!nav.contains(e.relatedTarget)) buttons.forEach(function (b) { set(b, false); });
});
}<nav>
<button aria-haspopup="true" aria-expanded="false">고객센터</button>
<ul role="menu">
<li role="menuitem"><a href="/faq">자주 묻는 질문</a></li>
</ul>
</nav><nav aria-label="주 메뉴">
<button aria-expanded="false" aria-controls="sub">고객센터</button>
<ul id="sub" hidden>
<li><a href="/faq">자주 묻는 질문</a></li>
</ul>
</nav>화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 메뉴 버튼에 포커스 | 편집, 메뉴 버튼, 축소됨 (또는 “버튼, 하위 메뉴 있음”) |
| Enter로 메뉴 열기 | 편집, 메뉴, 잘라내기, 메뉴 항목, 1/6 |
| ↓ 세 번 | 되돌리기, 메뉴 항목, 사용 불가, 4/6 |
| Esc로 닫기 | 편집, 메뉴 버튼, 축소됨 |
| 내비게이션 디스클로저 버튼에 포커스 | 고객센터, 버튼, 축소됨 |
| 펼친 뒤 Tab | 공지사항, 링크 |
| 링크 목록에 menu 역할을 쓴 경우 | 고객센터, 메뉴 버튼 … 공지사항, 메뉴 항목 (링크라는 사실이 드러나지 않음) |
점검 체크리스트
role="menu"는 명령·옵션 목록에만 쓰고, 링크 내비게이션은 디스클로저로 만들었습니다.- 메뉴 버튼에
aria-haspopup="menu"(또는true)와aria-expanded가 있습니다. - Enter·Space(·↓)로 메뉴가 열리고 포커스가 첫 항목으로 이동합니다.
- 메뉴 항목은 Tab 순서에 없고, ↑ ↓ Home End로 이동합니다.
- Esc로 닫으면 메뉴 버튼으로 포커스가 돌아옵니다.
- 항목을 실행하면 메뉴가 닫히고 포커스가 메뉴 버튼(또는 동작에 맞는 위치)으로 갑니다.
- Tab을 누르면 메뉴가 닫히고, 열린 채 남지 않습니다.
- 메뉴에 이름(
aria-labelledby)이 있고, 비활성 항목은aria-disabled="true"입니다. - 포커스가 있는 메뉴 항목이 눈에 띄게 표시됩니다.
- 메뉴 바깥을 클릭하면 닫힙니다.
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A) — 버튼과 메뉴, 메뉴와 항목의 관계가 역할로 표현되어야 합니다.
- 2.1.1 키보드 (A) — 메뉴를 열고 항목을 실행하는 모든 동작이 키보드로 가능해야 합니다.
- 2.1.2 키보드 트랩 없음 (A) — Esc와 Tab으로 메뉴를 빠져나올 수 있어야 합니다.
- 2.4.3 포커스 순서 (A) — 열 때 첫 항목으로, 닫을 때 버튼으로 포커스가 논리적으로 이동해야 합니다.
- 2.4.7 포커스 표시 (AA) — 현재 메뉴 항목이 눈에 보여야 합니다.
- 4.1.2 이름, 역할, 값 (A) — 메뉴 버튼의 팝업·펼침 상태와 항목의 역할·상태가 전달되어야 합니다.