컴포넌트 패턴
아코디언
제목 목록처럼 늘어선 여러 구역을 각각 펼치고 접는 패턴입니다. 각 제목 안의 버튼이 aria-expanded로 상태를 알리고, 패널은 필요하면 role="region"으로 제목과 연결합니다.
언제 쓰나요
아코디언은 제목이 붙은 여러 구역을 세로로 쌓고, 각 제목을 눌러 해당 내용을 펼치고 접게 하는 패턴입니다. 자주 묻는 질문(FAQ), 긴 설정 화면, 상품 상세 정보처럼 사용자가 필요한 구역만 골라 읽는 경우에 적합합니다. 구조적으로는 디스클로저 여러 개를 일관된 제목 구조로 묶은 것입니다.
- 네이티브 대안:
<details>/<summary>를 여러 개 두면 스크립트 없이 아코디언을 만들 수 있습니다. 같은name속성을 주면 하나만 열리는 배타적 아코디언이 됩니다(최신 브라우저 지원). - 한 번에 하나의 구역만 보이고 구역 간 전환이 핵심이라면 탭이 더 알맞을 수 있습니다. 반대로 화면이 좁거나 여러 구역을 함께 열어 비교해야 하면 아코디언이 낫습니다.
- 내용이 짧고 모두 중요하다면 접지 말고 그냥 보여 주는 편이 좋습니다. 접힌 내용은 페이지 검색이나 화면낭독기 가상 커서로 찾을 수 없습니다.
구조
div.accordion
├─ h3 ← 제목 (제목 탐색으로 이동 가능)
│ └─ button aria-expanded="true" aria-controls="panel-1" id="btn-1"
├─ div#panel-1 role="region" aria-labelledby="btn-1" ← 펼쳐진 패널
├─ h3
│ └─ button aria-expanded="false" aria-controls="panel-2" id="btn-2"
└─ div#panel-2 role="region" aria-labelledby="btn-2" hidden| 요소 | 역할 | 속성 |
|---|---|---|
| 아코디언 제목 | heading (<h2>~<h6>) | 페이지 제목 구조에 맞는 수준. div role="heading"이면 aria-level 필요 |
| 제목 버튼 | button | aria-expanded, aria-controls, 접을 수 없는 열린 패널이면 aria-disabled |
| 패널 | (선택) region | aria-labelledby → 제목 버튼 id |
제목 요소 안에는 버튼 하나만 둡니다. 제목 요소 자체에 role="button"을 주면 제목 의미가 덮어써지므로, 반드시 제목이 버튼을 감싸는 구조로 만듭니다.
키보드 동작
| 키 | 동작 |
|---|---|
| Enter 또는 Space | 포커스가 있는 제목의 패널을 펼치거나 접습니다. 한 번에 하나만 펼치는 아코디언이면 다른 패널을 접습니다. |
| Tab | 다음 포커스 가능한 요소로 이동합니다. 제목 버튼과 펼쳐진 패널 안의 링크·입력 요소가 모두 Tab 순서에 들어 있습니다. |
| Shift + Tab | 이전 포커스 가능한 요소로 이동합니다. |
| ↓ (선택) | 다음 아코디언 제목으로 포커스를 옮깁니다. 마지막에서는 첫 제목으로 돌아가거나 멈춥니다. |
| ↑ (선택) | 이전 아코디언 제목으로 포커스를 옮깁니다. |
| Home (선택) | 첫 아코디언 제목으로 이동합니다. |
| End (선택) | 마지막 아코디언 제목으로 이동합니다. |
화살표 키는 APG에서 선택 사항입니다. 넣더라도 Tab 이동을 대체하지 않으며, 제목 버튼들은 모두 Tab 순서에 남아 있어야 합니다(탭 패턴과 다른 점).
필요한 ARIA
heading·aria-level- 각 제목은 문서 구조에 맞는 제목 요소입니다. 화면낭독기 사용자는 제목 단위로 건너뛰며 원하는 구역을 찾습니다.
<h3>같은 네이티브 요소를 쓰면aria-level은 필요 없습니다. button·aria-expanded- 제목 안의 버튼이 펼침 상태를 갖습니다. 펼쳐지면
true, 접히면false입니다. 버튼의 이름은 제목 텍스트가 됩니다. aria-controls- 버튼이 제어하는 패널의
id를 가리킵니다. 스크립트에서 패널을 찾는 기준으로도 씁니다. region·aria-labelledby(선택)- 패널을 이름 있는 랜드마크 구역으로 만들어, 패널 안에 있다가도 어느 구역인지 알 수 있게 합니다. APG는 패널이 많을 때(대략 6개 넘게 동시에 펼쳐질 수 있을 때) 랜드마크가 지나치게 늘어나므로
region을 쓰지 말라고 권합니다. 이때는 역할 없이div로 둡니다. aria-disabled- “항상 하나는 열려 있어야 하는” 아코디언에서 이미 열린 패널의 버튼을 눌러도 접히지 않는다면, 그 버튼에
aria-disabled="true"를 주어 지금은 접을 수 없다는 사실을 알립니다.
직접 해 보기
Tab으로 이동해 Enter/Space로 펼치고, 올바른 구현에서는 ↑ ↓ Home End로 제목 사이를 이동해 보세요. “한 번에 하나만 펼치기”를 켜면 동작 방식이 바뀝니다.
잘못된 구현
실시간 코드
질문을 클릭 가능한 div로 만들었습니다. 키보드로 펼칠 수 없고, 제목 구조와 펼침 상태가 모두 빠져 있어 화면낭독기 사용자는 이것이 접힌 질문 목록이라는 사실조차 알 수 없습니다.
올바른 구현
평일 오후 2시 이전 주문은 당일 출고하며, 보통 1~2일 안에 도착합니다.
상품을 받은 날부터 7일 안에 마이페이지에서 신청할 수 있습니다.
주문 상세 화면의 “영수증 출력” 버튼으로 언제든 다시 받을 수 있습니다.
실시간 코드
첫 번째 버튼과 패널이 실시간 코드에 표시됩니다. 펼칠 때마다 aria-expanded와 hidden이 함께 바뀝니다.
전체 코드
<div class="accordion">
<h3>
<button type="button" class="ac-trigger" id="ac-btn-1"
aria-expanded="false" aria-controls="ac-panel-1">
<span class="ac-icon" aria-hidden="true"></span>배송은 얼마나 걸리나요?
</button>
</h3>
<div id="ac-panel-1" role="region" aria-labelledby="ac-btn-1" hidden>
<p>평일 오후 2시 이전 주문은 당일 출고하며, 보통 1~2일 안에 도착합니다.</p>
</div>
<h3>
<button type="button" class="ac-trigger" id="ac-btn-2"
aria-expanded="false" aria-controls="ac-panel-2">
<span class="ac-icon" aria-hidden="true"></span>교환·반품은 어떻게 하나요?
</button>
</h3>
<div id="ac-panel-2" role="region" aria-labelledby="ac-btn-2" hidden>
<p>상품을 받은 날부터 7일 안에 마이페이지에서 신청할 수 있습니다.</p>
</div>
</div>
<script>
initAccordion(document.querySelector('.accordion'), { single: false });
</script>// 아코디언: 제목(h3) 안의 버튼 + 패널 묶음
// options.single = true 이면 하나를 펼칠 때 나머지를 접습니다.
function initAccordion(root, options) {
options = options || {};
var triggers = Array.prototype.slice.call(root.querySelectorAll('.ac-trigger'));
function panelOf(btn) { return document.getElementById(btn.getAttribute('aria-controls')); }
function setOpen(btn, open) {
btn.setAttribute('aria-expanded', String(open));
panelOf(btn).hidden = !open;
}
triggers.forEach(function (btn) {
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') !== 'true';
if (open && options.single) {
triggers.forEach(function (other) { if (other !== btn) setOpen(other, false); });
}
setOpen(btn, open);
});
// (선택) 제목 사이 화살표 이동
btn.addEventListener('keydown', function (e) {
var i = triggers.indexOf(btn), next = null;
if (e.key === 'ArrowDown') next = triggers[(i + 1) % triggers.length];
else if (e.key === 'ArrowUp') next = triggers[(i - 1 + triggers.length) % triggers.length];
else if (e.key === 'Home') next = triggers[0];
else if (e.key === 'End') next = triggers[triggers.length - 1];
if (next) { e.preventDefault(); next.focus(); }
});
});
}<!-- 같은 name을 가진 details는 하나만 열립니다 (배타적 아코디언) -->
<details name="faq">
<summary>배송은 얼마나 걸리나요?</summary>
<p>평일 오후 2시 이전 주문은 당일 출고합니다.</p>
</details>
<details name="faq">
<summary>교환·반품은 어떻게 하나요?</summary>
<p>상품을 받은 날부터 7일 안에 신청할 수 있습니다.</p>
</details>“모두 펼치기” 버튼
구역이 많으면 아코디언 위에 “모두 펼치기/모두 접기” 버튼을 두면 편합니다. 이 버튼은 일반 버튼이며, 동작 후 각 제목 버튼의 aria-expanded도 모두 함께 갱신해야 합니다.
<h3 role="button" tabindex="0" aria-expanded="false">배송은 얼마나 걸리나요?</h3><h3><button type="button" aria-expanded="false" aria-controls="p1">배송은 얼마나 걸리나요?</button></h3>화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 제목 탐색(H 또는 3)으로 첫 질문 도착 | 제목 수준 3, 배송은 얼마나 걸리나요?, 버튼, 축소됨 |
| Tab으로 제목 버튼에 포커스 | 배송은 얼마나 걸리나요?, 버튼, 축소됨 |
| Enter로 펼침 | 확장됨 |
펼친 패널 안으로 읽어 들어감 (region 사용 시) | 배송은 얼마나 걸리나요?, 영역, 평일 오후 2시 이전 주문은 … |
| 한 번에 하나만 열리는데 열린 패널을 접을 수 없는 경우 | 배송은 얼마나 걸리나요?, 버튼, 확장됨, 사용 불가 |
점검 체크리스트
- 각 아코디언 제목은 페이지 구조에 맞는 제목 요소이고, 그 안에
<button>이 하나 있습니다. - 제목 버튼에
aria-expanded가 있고, 화면 상태와 항상 일치합니다. aria-controls가 실제 패널id를 가리킵니다.- 패널에
role="region"을 썼다면aria-labelledby로 제목 버튼과 연결했고, 패널 수가 지나치게 많지 않습니다. - 접힌 패널은
hidden으로 숨겨져 안의 요소가 Tab으로 잡히지 않습니다. - 모든 제목 버튼이 Tab 순서에 있고, 화살표 키를 넣었다면 Tab 이동도 그대로 동작합니다.
- 패널이 DOM 순서상 해당 제목 바로 뒤에 있습니다.
- 접을 수 없는 열린 패널의 버튼에는
aria-disabled="true"가 있습니다. - 펼침 아이콘은 장식으로 처리되어 버튼 이름에 섞이지 않습니다.
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A) — 제목 구조와 버튼·패널 관계가 마크업으로 표현되어야 합니다.
- 2.1.1 키보드 (A) — 모든 패널을 키보드로 펼치고 접을 수 있어야 합니다.
- 2.4.3 포커스 순서 (A) — 접힌 패널 안의 요소가 Tab 순서에 남지 않아야 합니다.
- 2.4.6 제목과 레이블 (AA) — 아코디언 제목이 내용을 잘 설명해야 합니다.
- 4.1.2 이름, 역할, 값 (A) — 제목 버튼의 역할·이름·펼침 상태가 전달되어야 합니다.