컴포넌트 패턴
탭
같은 자리에 겹쳐 놓은 여러 패널 가운데 하나를 골라 보여 주는 패턴입니다. 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| 요소 | 역할 | 속성 |
|---|---|---|
| 탭 목록 | tablist | aria-labelledby 또는 aria-label, 세로 배치면 aria-orientation="vertical" |
| 탭 | tab (tablist의 직접 자식) | aria-selected, aria-controls, 로빙 tabindex |
| 탭 패널 | tabpanel | aria-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-orientationtablist의 기본값은horizontal입니다. 탭을 세로로 쌓았다면vertical로 지정하고 위·아래 화살표를 지원합니다.
직접 해 보기
각 데모에서 Tab으로 탭 목록에 들어간 뒤 ← → Home End를 눌러 보고, 다시 Tab을 눌러 패널로 이동해 보세요.
잘못된 구현
이 상품은 접근성을 고려해 설계한 무선 키보드입니다.
실시간 코드
버튼 세 개와 CSS 클래스로만 만든 “탭”입니다. 화면낭독기에는 평범한 버튼 세 개로 들리고, 어느 것이 선택되었는지, 아래 내용이 어떤 버튼과 연결되는지 알 수 없습니다. 탭마다 Tab을 눌러야 패널에 도달합니다.
올바른 구현 — 자동 활성화
상품 정보
이 상품은 접근성을 고려해 설계한 무선 키보드입니다.
크기 280 × 115 mm, 무게 420 g, 블루투스 5.1을 지원합니다.
평균 별점 4.6점, 리뷰 128개가 있습니다.
실시간 코드
화살표로 포커스를 옮기면 바로 패널이 바뀌고, aria-selected와 tabindex가 함께 이동합니다.
올바른 구현 — 수동 활성화
상품 정보 (수동)
이 상품은 접근성을 고려해 설계한 무선 키보드입니다.
크기 280 × 115 mm, 무게 420 g, 블루투스 5.1을 지원합니다.
평균 별점 4.6점, 리뷰 128개가 있습니다.
실시간 코드
화살표는 포커스만 옮깁니다. 원하는 탭에서 Enter 또는 Space를 눌러야 aria-selected가 바뀌고 패널이 전환됩니다.
전체 코드
<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>// 탭: 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; });
});
}// 자동 활성화
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><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 성공 기준
- 1.3.1 정보와 관계 (A) — 탭과 패널의 관계가 역할·속성으로 표현되어야 합니다.
- 2.1.1 키보드 (A) — 모든 탭을 키보드로 선택할 수 있어야 합니다.
- 2.4.3 포커스 순서 (A) — 탭 목록 다음에 해당 패널로 포커스가 이어져야 합니다.
- 2.4.7 포커스 표시 (AA) — 포커스가 있는 탭과 선택된 탭이 구분되어 보여야 합니다.
- 3.2.1 포커스 시 (A) — 포커스만으로 예상치 못한 큰 변화(페이지 이동 등)가 일어나지 않아야 합니다. 자동 활성화는 같은 자리의 패널만 바뀌므로 허용됩니다.
- 4.1.2 이름, 역할, 값 (A) — 탭의 역할, 이름, 선택 상태가 전달되어야 합니다.