컴포넌트 패턴
리스트박스
항상 펼쳐진 목록에서 하나 또는 여러 항목을 고르는 패턴입니다. listbox와 option 역할, aria-selected로 선택 상태를 알리고, 여러 개를 고를 때는 aria-multiselectable을 씁니다. 네이티브 대안은 <select>입니다.
언제 쓰나요
리스트박스(listbox)는 화면에 펼쳐져 있는 선택 목록입니다. 사용자는 목록에서 항목 하나(단일 선택) 또는 여러 개(다중 선택)를 고릅니다. 이메일 앱의 폴더 목록, 파일 선택기의 파일 목록, 두 목록 사이에 항목을 옮기는 “이동 상자”처럼 항목을 계속 보여 주면서 골라야 할 때 씁니다. 콤보박스의 팝업 목록도 리스트박스입니다.
- 먼저 네이티브를 검토하세요. 단일 선택은
<select>, 항상 펼친 목록은<select size="5">, 다중 선택은<select multiple>로 대부분 해결됩니다. 키보드·화면낭독기·모바일 지원이 가장 안정적입니다. - 폼에서 몇 개 안 되는 선택지를 고른다면 라디오 버튼 묶음(라디오 그룹)이나 체크박스 묶음이 더 이해하기 쉽습니다. 특히 다중 선택 리스트박스는 조작법(Space, Shift)을 모르는 사용자가 많습니다.
- 항목 안에 버튼·링크·입력란 같은 다른 컨트롤이 있어야 한다면 리스트박스가 아닙니다.
option의 자식은 표현용으로 취급되어 전달되지 않습니다. 이때는 그리드나 일반 목록 + 버튼 구조를 씁니다. - 명령을 실행하는 목록이라면 메뉴, 페이지 이동이라면 링크 목록입니다.
구조
p#label "관심 분야"
ul[role=listbox] tabindex="0" aria-labelledby="label"
aria-multiselectable="true" ← 다중 선택일 때만
aria-activedescendant="opt-2" ← 현재 포커스 항목
├─ li#opt-0[role=option] aria-selected="true" 웹 접근성
├─ li#opt-1[role=option] aria-selected="false" 모바일 앱 접근성
└─ li#opt-2[role=option] aria-selected="false" 문서(PDF) 접근성 ← 가상 포커스| 요소 | 역할 | 속성 |
|---|---|---|
| 목록 | listbox | aria-labelledby/aria-label, 다중이면 aria-multiselectable, 가로 배치면 aria-orientation, 포커스 방식에 따라 aria-activedescendant |
| 항목 | option | aria-selected (또는 aria-checked), 일부만 불러온 목록이면 aria-setsize·aria-posinset |
| 항목 묶음 (선택) | group | 묶음 이름(aria-labelledby/aria-label) |
포커스 관리: 두 가지 방법
| 방법 | 구현 |
|---|---|
| aria-activedescendant (이 페이지 데모) | DOM 포커스는 listbox(tabindex="0")에 두고, 현재 항목의 id를 aria-activedescendant로 알립니다. 항목에는 tabindex가 없습니다. 현재 항목의 시각 표시는 CSS로 직접 그려야 합니다. |
| 로빙 tabindex | 현재 항목만 tabindex="0", 나머지는 -1로 두고 실제로 focus()를 옮깁니다. 브라우저의 포커스 표시와 스크롤이 자동으로 따라옵니다. |
키보드 동작
공통 (단일·다중)
| 키 | 동작 |
|---|---|
| Tab | 리스트박스로 포커스를 옮깁니다. 단일 선택이면 선택된 항목(없으면 첫 항목)에, 다중 선택이면 첫 번째 선택된 항목(없으면 첫 항목)에 포커스가 갑니다. |
| ↓ | 다음 항목으로 포커스를 옮깁니다. |
| ↑ | 이전 항목으로 포커스를 옮깁니다. |
| Home (선택) | 첫 항목으로 이동합니다. 항목이 5개를 넘으면 지원을 강력히 권장합니다. |
| End (선택) | 마지막 항목으로 이동합니다. |
| 문자 키 (권장) | 입력한 글자로 시작하는 다음 항목으로 이동합니다. 빠르게 여러 글자를 치면 그 글자들로 시작하는 항목으로 이동합니다. |
단일 선택에서는 포커스가 옮겨질 때 선택도 함께 따라가게 할 수 있습니다(선택 사항, 네이티브 <select>와 같은 방식). 아래 데모가 이 방식입니다.
다중 선택 (APG 권장 모델)
| 키 | 동작 |
|---|---|
| Space | 포커스가 있는 항목의 선택을 켜고 끕니다. |
| Shift + ↓ (선택) | 다음 항목으로 이동하며 그 항목의 선택을 토글합니다. |
| Shift + ↑ (선택) | 이전 항목으로 이동하며 그 항목의 선택을 토글합니다. |
| Shift + Space (선택) | 가장 최근에 선택한 항목부터 포커스가 있는 항목까지 연속으로 선택합니다. |
| Ctrl + Shift + Home (선택) | 포커스가 있는 항목부터 첫 항목까지 선택합니다. |
| Ctrl + Shift + End (선택) | 포커스가 있는 항목부터 마지막 항목까지 선택합니다. |
| Ctrl + A (선택) | 모든 항목을 선택합니다. 이미 모두 선택되어 있으면 모두 해제할 수 있습니다. |
다른 선택 모델
APG는 데스크톱 파일 탐색기와 비슷한 “대체 모델”도 설명합니다. 이 모델에서는 수식키 없이 화살표로 이동하면 다른 선택이 풀리고, Ctrl + 화살표로 선택을 유지한 채 이동, Ctrl + Space로 토글합니다. 수식키를 계속 눌러야 해 실수하기 쉬우므로, 특별한 이유가 없다면 위의 권장 모델을 씁니다.
필요한 ARIA
listbox- 선택 목록 컨테이너입니다. 화면낭독기는 “목록 상자”로 안내하고 포커스 모드로 전환해, 화살표 키가 목록 탐색에 쓰이게 합니다. 보이는 레이블을
aria-labelledby로 연결하거나aria-label로 이름을 줍니다. option- 선택할 수 있는 각 항목입니다. 반드시
listbox(또는 그 안의group) 안에 있어야 합니다. 항목의 텍스트가 이름이 되고, 자식 요소는 표현용으로 취급됩니다. aria-selected- 항목의 선택 상태입니다. 선택되면
true, 선택할 수 있지만 선택되지 않았으면false입니다. 다중 선택에서는 모든 항목에 값을 명시해야 화면낭독기가 “선택 안 됨”까지 안내할 수 있습니다. 선택을 색으로만 보여 주면 안 되며, 체크 표시 같은 모양 단서도 함께 둡니다. aria-checked(대안)- APG는 단일 선택에는
aria-selected, 다중 선택에는aria-checked를 쓰는 관례를 소개하면서도, 화면의 표현(체크박스 모양인지 강조 모양인지)과 사이트 전체의 일관성에 따라 정하라고 권합니다. 어느 쪽이든 한 리스트박스 안에서 둘을 섞지 않습니다. aria-multiselectable- 여러 항목을 고를 수 있는 목록이면
true입니다. 화면낭독기는 “다중 선택 가능”으로 안내합니다. 단일 선택이면 생략합니다. aria-activedescendant- DOM 포커스를
listbox에 둔 채 현재 항목을 가리킵니다. 항목이 바뀔 때마다 값을 갱신해야 화면낭독기가 새 항목을 읽습니다. 참조하는option은 반드시 그listbox안에 있어야 합니다. aria-orientation- 항목이 가로로 늘어선 목록이면
horizontal로 지정하고, 위·아래 대신 왼쪽·오른쪽 화살표로 이동하게 합니다. 기본값은vertical입니다. aria-setsize·aria-posinset(필요할 때)- 항목을 스크롤할 때마다 일부만 DOM에 불러오는 목록이라면, 전체 개수와 각 항목의 위치를 알려 “120개 중 37번째”처럼 정확히 안내되게 합니다.
직접 해 보기
각 목록에 Tab으로 들어가 ↑ ↓ Home End로 움직여 보세요. 단일 선택 목록에서는 P, J, T처럼 글자를 눌러 문자 탐색도 해 볼 수 있습니다.
잘못된 구현
주력 언어
- HTML
- CSS
- JavaScript
- TypeScript
- Python
- Java
선택: 없음
실시간 코드
클릭하면 CSS 클래스만 바뀌는 평범한 목록입니다. 키보드로 들어갈 수 없고, 화면낭독기는 “목록, 항목 6개”로만 읽어 무엇이 선택되었는지 알 수 없습니다.
올바른 구현 — 단일 선택
주력 언어
- HTML
- CSS
- JavaScript
- TypeScript
- Python
- Java
- Kotlin
- Swift
- Go
- Rust
선택: 없음
실시간 코드
화살표로 이동하면 aria-activedescendant와 aria-selected가 함께 바뀝니다(선택이 포커스를 따라감).
올바른 구현 — 다중 선택
관심 분야 (여러 개 선택)
- 웹 접근성
- 모바일 앱 접근성
- 문서(PDF) 접근성
- 사용성 평가
- 디자인 시스템
- 화면낭독기 테스트
- 자막·수어
- 인지 접근성
0개 선택
실시간 코드
Space로 토글, Shift + ↓/↑로 이동하며 토글, Shift + Space로 범위 선택, Ctrl + A로 전체 선택·해제를 해 보세요.
전체 코드
<!-- 단일 선택 -->
<p id="lang-label">주력 언어</p>
<ul role="listbox" id="lang" tabindex="0" aria-labelledby="lang-label">
<li role="option" id="lang-0">HTML</li>
<li role="option" id="lang-1">CSS</li>
<li role="option" id="lang-2">JavaScript</li>
</ul>
<!-- 다중 선택 -->
<p id="area-label">관심 분야</p>
<ul role="listbox" id="area" tabindex="0" aria-labelledby="area-label"
aria-multiselectable="true">
<li role="option" id="area-0" aria-selected="false">웹 접근성</li>
<li role="option" id="area-1" aria-selected="false">모바일 앱 접근성</li>
<li role="option" id="area-2" aria-selected="false">문서(PDF) 접근성</li>
</ul>
<script>
initListbox(document.getElementById('lang'), function (selected) { /* 단일 */ });
initListbox(document.getElementById('area'), function (selected) { /* 다중 */ });
</script>// 리스트박스: aria-activedescendant 방식 (DOM 포커스는 listbox 자체에)
// 단일 선택: 선택이 포커스를 따라갑니다. 다중 선택(aria-multiselectable="true"): APG 권장 모델.
function initListbox(listbox, onChange) {
var multi = listbox.getAttribute('aria-multiselectable') === 'true';
var options = Array.prototype.slice.call(listbox.querySelectorAll('[role="option"]'));
var last = options.length - 1;
var active = -1; // 현재 포커스(가상)가 있는 option 번호
var anchor = -1; // 범위 선택의 기준점 (마지막으로 선택을 바꾼 항목)
var typed = '', typedTimer = null;
function isSel(o) { return o.getAttribute('aria-selected') === 'true'; }
function setSel(o, on) { o.setAttribute('aria-selected', String(on)); }
function changed() { if (onChange) onChange(options.filter(isSel)); }
function setActive(i) {
if (active > -1) options[active].classList.remove('is-active');
active = i;
var o = options[i];
o.classList.add('is-active');
listbox.setAttribute('aria-activedescendant', o.id);
o.scrollIntoView({ block: 'nearest' });
if (!multi) { // 단일 선택: 포커스 = 선택
options.forEach(function (x) { setSel(x, x === o); });
changed();
}
}
function toggle(i) {
setSel(options[i], !isSel(options[i]));
anchor = i;
changed();
}
function selectRange(a, b) {
var lo = Math.min(a, b), hi = Math.max(a, b);
for (var i = lo; i <= hi; i++) setSel(options[i], true);
changed();
}
// 처음 포커스: 선택된 항목(없으면 첫 항목)
listbox.addEventListener('focus', function () {
if (active > -1) return;
var firstSel = options.filter(isSel)[0];
setActive(firstSel ? options.indexOf(firstSel) : 0);
});
listbox.addEventListener('keydown', function (e) {
var key = e.key;
if (key === 'ArrowDown' || key === 'ArrowUp') {
e.preventDefault();
var next = key === 'ArrowDown' ? Math.min(active + 1, last) : Math.max(active - 1, 0);
if (next === active) return;
setActive(next);
if (multi && e.shiftKey) toggle(next); // Shift + 화살표: 이동하며 선택 토글
} else if (key === 'Home' || key === 'End') {
e.preventDefault();
var target = key === 'Home' ? 0 : last;
if (multi && e.shiftKey && (e.ctrlKey || e.metaKey)) selectRange(active, target);
setActive(target);
} else if (key === ' ') {
e.preventDefault();
if (!multi) return;
if (e.shiftKey && anchor > -1) selectRange(anchor, active); // Shift + Space: 범위 선택
else toggle(active); // Space: 선택 토글
} else if (multi && (e.ctrlKey || e.metaKey) && key.toLowerCase() === 'a') {
e.preventDefault();
var all = options.every(isSel); // 모두 선택되어 있으면 모두 해제
options.forEach(function (o) { setSel(o, !all); });
changed();
} else if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// 문자 탐색(type-ahead): 빠르게 친 글자로 시작하는 항목으로 이동
clearTimeout(typedTimer);
typed += key.toLowerCase();
typedTimer = setTimeout(function () { typed = ''; }, 500);
var start = typed.length === 1 ? active + 1 : active;
for (var n = 0; n < options.length; n++) {
var idx = (start + n) % options.length;
if (options[idx].textContent.trim().toLowerCase().indexOf(typed) === 0) { setActive(idx); break; }
}
}
});
// 마우스: 단일은 클릭한 항목 선택, 다중은 토글 (Shift + 클릭은 범위 선택)
listbox.addEventListener('mousedown', function (e) {
var o = e.target.closest('[role="option"]');
if (!o) return;
e.preventDefault();
var i = options.indexOf(o);
if (multi) {
if (e.shiftKey && anchor > -1) selectRange(anchor, i); else toggle(i);
}
setActive(i);
listbox.focus();
});
}/* 가상 포커스(aria-activedescendant)와 선택 상태를 각각 다르게 표시합니다 */
[role="listbox"]:focus { outline: 2px solid Highlight; }
[role="listbox"]:focus [role="option"].is-active { outline: 2px dashed currentColor; outline-offset: -2px; }
[role="option"][aria-selected="true"] { background: Highlight; color: HighlightText; }
[aria-multiselectable="true"] [role="option"]::before { content: "☐ "; }
[aria-multiselectable="true"] [role="option"][aria-selected="true"]::before { content: "☑ "; }<ul role="listbox" aria-label="관심 분야">
<li role="option" class="selected">웹 접근성</li>
<li role="option">모바일 앱 접근성</li>
</ul><ul role="listbox" tabindex="0" aria-label="관심 분야"
aria-multiselectable="true" aria-activedescendant="o1">
<li role="option" id="o1" aria-selected="true">웹 접근성</li>
<li role="option" id="o2" aria-selected="false">모바일 앱 접근성</li>
</ul><!-- 직접 만든 단일 선택 리스트박스 -->
<ul role="listbox" tabindex="0" aria-label="배송 방법">…</ul><label for="ship">배송 방법</label>
<select id="ship" size="4">
<option>일반 배송</option>
<option>빠른 배송</option>
</select>화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 단일 선택 목록에 Tab | 주력 언어, 목록 상자, HTML, 선택됨, 1/10 |
| ↓ | CSS, 선택됨, 2/10 |
| P (문자 탐색) | Python, 선택됨, 5/10 |
| 다중 선택 목록에 Tab | 관심 분야, 목록 상자, 다중 선택 가능, 웹 접근성, 선택 안 됨, 1/8 |
| Space | 선택됨 |
| Shift + ↓ | 모바일 앱 접근성, 선택됨, 2/8 |
| 잘못된 구현을 가상 커서로 읽음 | 목록, 항목 6개, HTML … (선택 상태 안내 없음) |
점검 체크리스트
- 네이티브
<select>·라디오·체크박스로 해결할 수 없는 경우에만 직접 만들었습니다. - 목록에
role="listbox"와 이름이 있고, 각 항목은role="option"입니다. - 리스트박스가 Tab 순서에 한 번만 들어가고, 항목 사이는 화살표로 이동합니다.
- 처음 들어가면 선택된 항목(없으면 첫 항목)에 포커스가 갑니다.
aria-activedescendant방식이면 값이 현재 항목id로 갱신되고, 현재 항목이 눈에 보이게 표시됩니다.- 선택 상태가
aria-selected(또는aria-checked한 가지)로 전달되고, 다중 선택에서는 모든 항목에 값이 있습니다. - 다중 선택 목록에
aria-multiselectable="true"가 있고, Space로 토글할 수 있습니다. - 항목이 많으면 Home·End와 문자 탐색을 지원합니다.
- 포커스(현재 항목)와 선택 상태가 서로 다른 모양으로 구분되고, 색에만 의존하지 않습니다.
- 항목 안에 링크·버튼 같은 다른 컨트롤을 넣지 않았습니다.
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A) — 목록과 항목, 선택 상태가 역할·속성으로 표현되어야 합니다.
- 1.4.1 색의 사용 (A) — 선택 상태를 색만으로 구분하지 않아야 합니다.
- 2.1.1 키보드 (A) — 항목 탐색과 선택이 모두 키보드로 가능해야 합니다.
- 2.4.7 포커스 표시 (AA) — 현재 항목(가상 포커스)이 눈에 보여야 합니다.
- 4.1.2 이름, 역할, 값 (A) — 리스트박스의 이름과 각 항목의 선택 상태가 전달되어야 합니다.