ARIA 접근성 가이드

컴포넌트 패턴

리스트박스

항상 펼쳐진 목록에서 하나 또는 여러 항목을 고르는 패턴입니다. 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) 접근성   ← 가상 포커스
구조
요소역할속성
목록listboxaria-labelledby/aria-label, 다중이면 aria-multiselectable, 가로 배치면 aria-orientation, 포커스 방식에 따라 aria-activedescendant
항목optionaria-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 권장 모델)

다중 선택 (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로 전체 선택·해제를 해 보세요.

전체 코드

HTML
<!-- 단일 선택 -->
<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>
JavaScript
// 리스트박스: 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();
  });
}
CSS — 포커스와 선택 표시
/* 가상 포커스(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>
권장권장 — 포커스 가능, aria-selected 명시
<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>
권장네이티브 select로 충분한 경우
<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 성공 기준

참고 자료