ARIA 접근성 가이드

컴포넌트 패턴

탭

같은 자리에 겹쳐 놓은 여러 패널 가운데 하나를 골라 보여 주는 패턴입니다. tablist·tab·tabpanel 역할과 aria-selected로 구조와 선택 상태를 알리고, 탭 사이는 화살표 키로 이동합니다.

언제 쓰나요

탭은 같은 자리를 차지하는 여러 패널 중 하나만 보여 주고, 위쪽의 탭 목록으로 패널을 바꾸는 패턴입니다. 상품 상세의 “개요·사양·리뷰”, 설정 화면의 분류처럼 서로 대등한 구역을 좁은 공간에 담을 때 씁니다.

  • 페이지 이동이라면 탭이 아닙니다. 생김새가 탭이어도 누르면 다른 URL로 이동하는 사이트 내비게이션은 <nav> 안의 링크 목록으로 만들고, 현재 페이지는 aria-current="page"로 표시합니다. role="tab"을 쓰지 않습니다.
  • 여러 구역을 동시에 펼쳐 볼 필요가 있거나 화면이 좁다면 아코디언을 검토합니다.
  • HTML에는 탭에 해당하는 네이티브 요소가 없습니다. 탭이 꼭 필요하지 않다면 제목과 본문을 그냥 차례로 보여 주는 것이 가장 접근성이 좋습니다.

구조

탭 구조
div[role=tablist]  aria-labelledby="제목 id"         ← 탭 묶음 (이름 필요)
├─ button[role=tab]  aria-selected="true"   aria-controls="p1"  tabindex="0"
├─ button[role=tab]  aria-selected="false"  aria-controls="p2"  tabindex="-1"
└─ button[role=tab]  aria-selected="false"  aria-controls="p3"  tabindex="-1"
div[role=tabpanel]#p1  aria-labelledby="탭1 id"  tabindex="0"
div[role=tabpanel]#p2  aria-labelledby="탭2 id"  hidden
div[role=tabpanel]#p3  aria-labelledby="탭3 id"  hidden
구조
요소역할속성
탭 목록tablistaria-labelledby 또는 aria-label, 세로 배치면 aria-orientation="vertical"
탭tab (tablist의 직접 자식)aria-selected, aria-controls, 로빙 tabindex
탭 패널tabpanelaria-labelledby → 해당 탭, 필요하면 tabindex="0"

tab은 반드시 tablist 안에 있어야 하고(필수 상위 역할), tablist와 tab 사이에 다른 요소를 끼울 때는 그 요소에 role="none"을 줍니다. 탭을 <ul><li>로 감쌌다면 ul은 tablist, li는 role="none", 그 안의 버튼을 tab으로 만듭니다.

키보드 동작

키보드 동작
키동작
Tab탭 목록으로 들어오면 선택된 탭으로 포커스가 갑니다. 탭 목록 안에서 다시 누르면 탭 패널(또는 패널 안의 첫 포커스 가능한 요소)로 이동합니다.
→다음 탭으로 포커스를 옮깁니다. 마지막 탭에서는 첫 탭으로 돌아갑니다. 자동 활성화라면 패널도 함께 바뀝니다.
←이전 탭으로 포커스를 옮깁니다. 첫 탭에서는 마지막 탭으로 갑니다.
Home (선택)첫 탭으로 이동합니다.
End (선택)마지막 탭으로 이동합니다.
Enter 또는 Space수동 활성화에서 포커스가 있는 탭을 선택해 패널을 보여 줍니다. 자동 활성화에서는 이미 선택되어 있습니다.
Delete (선택)탭을 닫을 수 있는 인터페이스라면 현재 탭을 삭제하고 포커스를 이웃 탭으로 옮깁니다.

세로 탭(aria-orientation="vertical")에서는 ← → 대신 ↑ ↓를 씁니다.

자동 활성화와 수동 활성화

자동 활성화와 수동 활성화
방식동작알맞은 경우
자동 활성화화살표로 탭에 포커스가 가면 즉시 그 탭이 선택되고 패널이 바뀝니다.패널 내용이 이미 페이지에 있어 지연 없이 바로 보일 때 (APG 권장 기본값)
수동 활성화화살표는 포커스만 옮기고, Enter/Space를 눌러야 선택됩니다.패널을 표시할 때 네트워크 요청 등으로 지연이 생겨, 화살표를 누를 때마다 기다려야 한다면

필요한 ARIA

tablist
탭들을 묶는 컨테이너입니다. 화면낭독기는 “탭 목록”으로 안내하고 탭 개수를 알려 줍니다. 무엇에 대한 탭인지 보이는 제목이 있으면 aria-labelledby로, 없으면 aria-label로 이름을 줍니다.
tab · aria-selected
각 탭입니다. 현재 패널을 보여 주는 탭은 aria-selected="true", 나머지는 "false"입니다. 화면낭독기는 “선택됨”과 “2/3” 같은 위치 정보를 안내합니다. 선택 상태를 클래스(색)로만 표시하면 전달되지 않습니다.
로빙 tabindex
선택된 탭만 tabindex="0", 나머지는 tabindex="-1"입니다. 그래서 Tab 한 번으로 탭 목록을 지나 패널로 갈 수 있고, 탭 사이 이동은 화살표가 맡습니다. (키보드와 포커스 참고)
aria-controls
탭이 보여 주는 패널의 id입니다.
tabpanel · aria-labelledby
탭 패널은 대응하는 탭으로 이름을 받습니다. 그래서 패널 안으로 들어가면 “사양, 탭 패널”처럼 어느 패널인지 안내됩니다. 선택되지 않은 패널은 hidden으로 숨깁니다.
패널의 tabindex="0"
패널 안에 포커스 가능한 요소가 없거나, 첫 포커스 가능한 요소가 한참 아래에 있으면 패널 자체에 tabindex="0"을 주어 Tab으로 패널에 바로 도달하게 합니다. APG 예제도 이 방식을 씁니다.
aria-orientation
tablist의 기본값은 horizontal입니다. 탭을 세로로 쌓았다면 vertical로 지정하고 위·아래 화살표를 지원합니다.

직접 해 보기

각 데모에서 Tab으로 탭 목록에 들어간 뒤 ← → Home End를 눌러 보고, 다시 Tab을 눌러 패널로 이동해 보세요.

잘못된 구현

이 상품은 접근성을 고려해 설계한 무선 키보드입니다.

버튼 세 개와 CSS 클래스로만 만든 “탭”입니다. 화면낭독기에는 평범한 버튼 세 개로 들리고, 어느 것이 선택되었는지, 아래 내용이 어떤 버튼과 연결되는지 알 수 없습니다. 탭마다 Tab을 눌러야 패널에 도달합니다.

올바른 구현 — 자동 활성화

상품 정보

이 상품은 접근성을 고려해 설계한 무선 키보드입니다.

화살표로 포커스를 옮기면 바로 패널이 바뀌고, aria-selected와 tabindex가 함께 이동합니다.

올바른 구현 — 수동 활성화

상품 정보 (수동)

이 상품은 접근성을 고려해 설계한 무선 키보드입니다.

화살표는 포커스만 옮깁니다. 원하는 탭에서 Enter 또는 Space를 눌러야 aria-selected가 바뀌고 패널이 전환됩니다.

전체 코드

HTML
<div class="tabs">
  <h3 class="tb-label" id="tabs-label">상품 정보</h3>
  <div role="tablist" aria-labelledby="tabs-label" id="tabs-list">
    <button type="button" role="tab" id="tabs-tab-1" aria-selected="true" aria-controls="tabs-panel-1">개요</button>
    <button type="button" role="tab" id="tabs-tab-2" aria-selected="false" aria-controls="tabs-panel-2" tabindex="-1">사양</button>
    <button type="button" role="tab" id="tabs-tab-3" aria-selected="false" aria-controls="tabs-panel-3" tabindex="-1">리뷰</button>
  </div>
  <div role="tabpanel" id="tabs-panel-1" aria-labelledby="tabs-tab-1" tabindex="0">
    <p>이 상품은 접근성을 고려해 설계한 무선 키보드입니다.</p>
  </div>
  <div role="tabpanel" id="tabs-panel-2" aria-labelledby="tabs-tab-2" tabindex="0" hidden>
    <p>크기 280 × 115 mm, 무게 420 g, 블루투스 5.1을 지원합니다.</p>
  </div>
  <div role="tabpanel" id="tabs-panel-3" aria-labelledby="tabs-tab-3" tabindex="0" hidden>
    <p>평균 별점 4.6점, 리뷰 128개가 있습니다.</p>
  </div>
</div>
JavaScript
// 탭: tablist 하나를 초기화합니다.
// manual = false → 자동 활성화(화살표로 포커스가 가면 바로 패널 전환)
// manual = true  → 수동 활성화(화살표는 포커스만 이동, Enter/Space로 전환)
function initTabs(tablist, manual) {
  var tabs = Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
  var vertical = tablist.getAttribute('aria-orientation') === 'vertical';

  function select(tab) {
    tabs.forEach(function (t) {
      var on = t === tab;
      t.setAttribute('aria-selected', String(on));
      t.tabIndex = on ? 0 : -1;               // 로빙 tabindex: 선택된 탭만 Tab 순서에
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    });
  }

  function moveFocus(tab) {
    if (manual) {
      // 수동: 포커스만 옮기고, 포커스 받은 탭이 Tab 순서를 이어받습니다.
      tabs.forEach(function (t) { t.tabIndex = t === tab ? 0 : -1; });
    } else {
      select(tab);
    }
    tab.focus();
  }

  tabs.forEach(function (tab, i) {
    // 마우스 클릭, Enter, Space (button 요소라 click 하나로 처리)
    tab.addEventListener('click', function () { select(tab); });

    tab.addEventListener('keydown', function (e) {
      var prev = vertical ? 'ArrowUp' : 'ArrowLeft';
      var next = vertical ? 'ArrowDown' : 'ArrowRight';
      var target = null;
      if (e.key === next) target = tabs[(i + 1) % tabs.length];
      else if (e.key === prev) target = tabs[(i - 1 + tabs.length) % tabs.length];
      else if (e.key === 'Home') target = tabs[0];
      else if (e.key === 'End') target = tabs[tabs.length - 1];
      if (target) {
        e.preventDefault();
        moveFocus(target);
      }
    });
  });

  // 수동 모드에서 포커스가 tablist를 떠나면 Tab 순서를 선택된 탭으로 되돌립니다.
  tablist.addEventListener('focusout', function (e) {
    if (!manual || tablist.contains(e.relatedTarget)) return;
    var selected = tablist.querySelector('[aria-selected="true"]');
    tabs.forEach(function (t) { t.tabIndex = t === selected ? 0 : -1; });
  });
}
JavaScript — 사용 예
// 자동 활성화
initTabs(document.getElementById('tabs-list'), false);
// 수동 활성화
// initTabs(document.getElementById('tabs-list'), true);
비권장비권장 — 링크 목록 + 클래스로만 선택 표시
<ul class="tabs">
  <li class="active"><a href="#p1">개요</a></li>
  <li><a href="#p2">사양</a></li>
</ul>
권장권장 — tablist·tab·aria-selected
<div role="tablist" aria-label="상품 정보">
  <button role="tab" aria-selected="true" aria-controls="p1">개요</button>
  <button role="tab" aria-selected="false" aria-controls="p2" tabindex="-1">사양</button>
</div>

탭 레이블에 숫자·아이콘을 넣을 때

탭 레이블에 “(3)”처럼 숫자 배지를 붙일 때는 숫자가 이름의 일부로 자연스럽게 읽히는지 확인합니다. 아이콘만 있는 탭은 aria-label로 이름을 주고, 가능하면 보이는 텍스트도 함께 둡니다.

화면낭독기에서는

화면낭독기 안내 예시 (표현은 화면낭독기·브라우저·버전에 따라 조금씩 다릅니다)
상황들리는 안내 (예)
Tab으로 탭 목록에 들어옴상품 정보, 탭 목록, 개요, 탭, 선택됨, 1/3
→로 다음 탭 (자동 활성화)사양, 탭, 선택됨, 2/3
→로 다음 탭 (수동 활성화, 아직 선택 전)사양, 탭, 2/3 (“선택됨” 없음)
수동 활성화에서 Enter선택됨
Tab으로 패널 이동사양, 탭 패널, 크기 280 × 115 mm …
클래스로만 만든 탭에 포커스사양, 버튼 (선택 여부·위치 안내 없음)

점검 체크리스트

  • 탭 묶음에 role="tablist"와 이름(aria-labelledby/aria-label)이 있습니다.
  • 각 탭은 role="tab"이고 tablist의 직접 자식(또는 role="none" 래퍼 안)입니다.
  • 선택된 탭만 aria-selected="true"이고, 화면 표시와 일치합니다.
  • 선택된 탭만 tabindex="0"이어서 Tab 한 번으로 탭 목록을 지나갑니다.
  • ← →(세로면 ↑ ↓)로 탭 사이를 이동하고, 양 끝에서 순환합니다.
  • 각 패널은 role="tabpanel", aria-labelledby로 탭과 연결되어 있고, 선택되지 않은 패널은 hidden입니다.
  • 패널 안에 포커스 가능한 요소가 없으면 패널에 tabindex="0"이 있습니다.
  • 패널 표시가 느리다면 수동 활성화를 씁니다.
  • 페이지 이동용 내비게이션에는 탭 역할을 쓰지 않았습니다.

관련 WCAG 성공 기준

참고 자료