컴포넌트 패턴
버튼과 토글 버튼
누르면 동작을 실행하는 버튼과, 눌림·안 눌림 두 상태를 오가는 토글 버튼을 만드는 방법입니다. 네이티브 <button>이 기본이며, aria-pressed로 토글 상태를 알립니다.
언제 쓰나요
버튼은 누르면 그 자리에서 무언가를 실행하는 컨트롤입니다. 폼 제출, 대화상자 열기, 장바구니에 담기, 목록 정렬처럼 페이지 안에서 동작을 일으킬 때 씁니다. 다른 페이지나 같은 페이지의 다른 위치로 이동한다면 버튼이 아니라 링크(<a href>)를 씁니다.
토글 버튼은 버튼 가운데 눌림(켜짐)·안 눌림(꺼짐) 두 상태를 갖는 버튼입니다. 편집기의 “굵게”, 동영상의 “음소거”, 필터 칩처럼 같은 레이블을 유지한 채 상태만 바뀌는 경우에 씁니다. 상태는 aria-pressed로 알립니다.
먼저 네이티브 요소를 쓰세요
HTML <button>은 역할(button), 키보드 포커스, Enter·Space 실행, disabled 처리, 폼 연동을 모두 기본으로 제공합니다. <div role="button">은 이 가운데 역할만 줄 뿐이므로, 나머지는 모두 스크립트로 직접 만들어야 합니다. 디자인 시스템 제약이나 레거시 코드 때문에 피할 수 없을 때만 씁니다.
이럴 때는 다른 패턴을 고르세요
| 상황 | 알맞은 선택 |
|---|---|
| 다른 페이지·위치로 이동 | 링크 <a href> |
| 설정을 “켜기/끄기”로 표현 (즉시 적용되는 환경 설정) | 스위치 (switch) 또는 체크박스 |
| 폼에서 여러 항목 중 고르기·동의하기 | 체크박스 <input type="checkbox"> |
| 누르면 아래 내용이 펼쳐짐 | 디스클로저 (버튼 + aria-expanded) |
| 누르면 명령 목록이 열림 | 메뉴 버튼 (버튼 + aria-haspopup) |
| 눌렀을 때 레이블 자체가 바뀜 (예: 재생 → 일시정지) | 일반 버튼 (레이블 변경만으로 상태 전달, aria-pressed 사용 안 함) |
토글 버튼의 레이블은 바꾸지 않습니다
APG는 토글 버튼의 레이블이 상태에 따라 바뀌면 안 된다고 명시합니다. “음소거” 버튼에 aria-pressed="true"를 주면서 레이블까지 “음소거 해제”로 바꾸면 “음소거 해제, 토글 버튼, 누름”처럼 들려 지금 소리가 나는지 알기 어렵습니다. 레이블을 바꾸고 싶다면 aria-pressed를 빼고 일반 버튼으로 만듭니다.
구조
| 종류 | 마크업 | 역할 | 상태·속성 |
|---|---|---|---|
| 일반 버튼 | <button type="button"> | button (암묵적) | 필요하면 disabled 또는 aria-disabled |
| 토글 버튼 | <button type="button" aria-pressed="false"> | button (전환 버튼으로 안내) | aria-pressed = true / false (드물게 mixed) |
| 아이콘 버튼 | <button aria-label="검색"><svg aria-hidden="true"> | button | aria-label 또는 숨김 텍스트로 이름 제공 |
| 사용자 정의 버튼 | <div role="button" tabindex="0"> | button (명시) | tabindex="0" + Enter/Space 처리 필수 |
버튼의 접근 가능한 이름은 기본적으로 안의 텍스트에서 계산됩니다. 화면에 보이는 텍스트가 있으면 그것이 이름이 되고, 아이콘만 있을 때만 aria-label이나 sr-only 텍스트로 이름을 붙입니다. 버튼 역할은 자식을 표현용(presentational)으로 취급하므로, 버튼 안에 제목·목록 같은 구조를 넣어도 화면낭독기에는 전달되지 않습니다.
키보드 동작
| 키 | 동작 |
|---|---|
| Tab | 버튼으로 포커스를 옮깁니다. (disabled 버튼은 건너뜁니다.) |
| Enter | 버튼을 실행합니다. 토글 버튼이면 상태를 바꿉니다. |
| Space | 버튼을 실행합니다. 토글 버튼이면 상태를 바꿉니다. |
실행 후 포커스 위치는 동작에 따라 다릅니다. APG 기준으로 정리하면 다음과 같습니다.
- 대화상자를 여는 버튼: 포커스가 대화상자 안으로 이동합니다. (모달 대화상자 참고)
- 대화상자를 닫는 버튼(“취소” 등): 대화상자를 연 요소로 포커스가 돌아갑니다.
- 그 자리에서 끝나는 동작(토글, “적용” 등): 포커스는 대개 버튼에 그대로 남습니다.
- 버튼이 사라지는 동작(목록 항목 “삭제” 등): 포커스가 문서 맨 처음으로 튀지 않도록 다음 항목이나 목록 제목 같은 논리적인 위치로 옮깁니다.
네이티브 버튼의 실행 시점
네이티브 <button>은 Enter를 누르는 순간(keydown), Space는 떼는 순간(keyup) click 이벤트를 발생시킵니다. div role="button"을 만들 때도 이 차이를 따라 하면 사용자가 느끼는 동작이 네이티브와 같아집니다. Space의 keydown에서는 preventDefault()로 페이지가 스크롤되지 않게 막습니다.
필요한 ARIA
button- 네이티브
<button>에는 이미 들어 있으므로 따로 쓰지 않습니다.div·span을 버튼으로 만들 때만 씁니다. 이 경우tabindex="0"과 키보드 처리를 반드시 함께 넣습니다. 역할은 “이것은 버튼이다”라는 약속일 뿐, 동작을 만들어 주지 않습니다. aria-pressed- 토글 버튼의 현재 상태입니다.
true(눌림),false(안 눌림),mixed(일부만 적용, 예: 선택한 글자 일부만 굵을 때). 이 속성이 있는 것만으로 화면낭독기는 “전환 버튼”으로 안내하므로, 일반 버튼에는 넣지 않습니다. 값은"false"로 명시해야 하며 속성을 빼면 토글 버튼이 아니게 됩니다. aria-disabled- 버튼을 비활성으로 알리되 포커스는 유지하고 싶을 때 씁니다. 네이티브
disabled는 Tab 순서에서 빠지므로 “왜 못 누르는지” 설명을 듣기 어렵습니다.aria-disabled="true"는 실제 동작을 막지 않으므로 스크립트에서 클릭·키 입력을 무시하도록 처리합니다. aria-label·aria-labelledby- 보이는 텍스트가 없는 아이콘 버튼에만 씁니다. 보이는 텍스트가 있는데 다른
aria-label을 주면 음성 명령 사용자가 화면의 글자로 버튼을 부를 수 없습니다(WCAG 2.5.3). aria-describedby- “이 작업은 되돌릴 수 없습니다”처럼 이름 외에 보충 설명이 필요할 때 설명 요소를 연결합니다.
aria-expanded·aria-haspopup- 버튼이 다른 영역을 펼치거나 팝업을 열 때 씁니다. 이 경우는 각각 디스클로저, 메뉴 버튼 패턴을 따릅니다.
aria-pressed와aria-expanded를 한 버튼에 함께 쓰지 않습니다.
직접 해 보기
버튼: div와 button 비교
마우스로 눌러 보고, Tab으로 이동해 Enter·Space로도 눌러 보세요. 왼쪽 div는 Tab으로 갈 수조차 없습니다.
잘못된 구현
실시간 코드
클릭 이벤트만 붙인 div입니다. 역할이 없어 화면낭독기는 일반 텍스트로 읽고, 포커스를 받을 수 없어 키보드로 누를 수 없습니다.
올바른 구현
실시간 코드
첫째는 네이티브 <button>, 둘째는 role="button" + tabindex="0" + 키보드 처리를 모두 갖춘 div입니다. 두 버튼은 사용자에게 똑같이 동작하지만, 둘째는 이 모든 것을 직접 구현해야 합니다.
토글 버튼: 색만 바꾸기와 aria-pressed 비교
“굵게”를 눌러 보면서 실시간 코드를 비교해 보세요.
잘못된 구현
버튼을 누르면 이 문장이 굵게 바뀝니다.
실시간 코드
눌림 상태를 CSS 클래스(색)로만 표시합니다. 화면을 보지 못하는 사용자는 지금 켜져 있는지 알 수 없습니다.
올바른 구현
버튼을 누르면 이 문장이 굵게 바뀝니다.
실시간 코드
“굵게”는 레이블을 고정하고 aria-pressed 값만 바꿉니다. 스타일도 [aria-pressed="true"] 선택자로 입혀 상태와 화면이 어긋나지 않습니다. “재생”은 레이블 자체가 상태를 알려 주므로 aria-pressed 없이 일반 버튼으로 둡니다.
전체 코드
<!-- 1. 기본 버튼: 가능하면 항상 네이티브 <button> -->
<button type="button" id="add">장바구니에 담기</button>
<!-- 2. 어쩔 수 없이 div를 써야 할 때: 역할 + 포커스 + 키보드를 모두 갖춥니다 -->
<div role="button" tabindex="0" id="add-custom">장바구니에 담기</div>
<!-- 3. 토글 버튼: 레이블은 고정, 상태는 aria-pressed -->
<button type="button" id="bold" aria-pressed="false">굵게</button>
<!-- 4. 레이블이 바뀌는 버튼: aria-pressed를 쓰지 않습니다 -->
<button type="button" id="play">재생</button>
<!-- 5. 아이콘만 있는 버튼: 접근 가능한 이름을 따로 줍니다 -->
<button type="button" aria-label="검색">
<svg aria-hidden="true" focusable="false" width="16" height="16">…</svg>
</button>// 1) 토글 버튼: aria-pressed 값만 바꾸고, 레이블은 그대로 둡니다.
function initToggleButton(btn, onChange) {
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
if (onChange) onChange(!pressed);
});
}
// 2) div role="button": 네이티브 <button>이 기본으로 해 주는 일을 직접 구현합니다.
function initCustomButton(el, onActivate) {
function isDisabled() { return el.getAttribute('aria-disabled') === 'true'; }
el.addEventListener('click', function (e) {
if (!isDisabled()) onActivate(e);
});
el.addEventListener('keydown', function (e) {
if (isDisabled()) return;
if (e.key === 'Enter') { // Enter: 누르는 즉시 실행
e.preventDefault();
onActivate(e);
} else if (e.key === ' ') { // Space: 페이지 스크롤만 막고
e.preventDefault();
}
});
el.addEventListener('keyup', function (e) {
if (e.key === ' ' && !isDisabled()) { // 키를 뗄 때 실행 (네이티브와 같게)
e.preventDefault();
onActivate(e);
}
});
}initCustomButton(document.getElementById('add-custom'), function () {
// 장바구니에 담는 동작
});
initToggleButton(document.getElementById('bold'), function (pressed) {
document.getElementById('editor').classList.toggle('is-bold', pressed);
});<button type="button" aria-pressed="true">음소거 해제</button><button type="button" aria-pressed="true">음소거</button>상태와 스타일을 한곳에서
토글 버튼의 모양은 .is-active 같은 별도 클래스 대신 button[aria-pressed="true"] 선택자로 지정하세요. ARIA 값을 바꾸지 않으면 화면도 바뀌지 않으므로, 상태를 알리지 않는 실수를 개발 중에 바로 발견할 수 있습니다. (상태 동기화 참고)
화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 네이티브 버튼에 포커스 | 장바구니에 담기, 버튼 |
div role="button" tabindex="0"에 포커스 | 장바구니에 담기, 버튼 |
클릭만 붙인 div (가상 커서로 읽을 때) | 장바구니에 담기 (버튼이라는 안내 없음, Tab으로 도달 불가) |
| 안 눌린 토글 버튼에 포커스 | 굵게, 전환 버튼, 누르지 않음 |
| 토글 버튼을 눌렀을 때 | 누름 |
| 색만 바뀌는 버튼을 눌렀을 때 | (아무 상태 변화도 안내되지 않음) |
aria-disabled="true" 버튼에 포커스 | 삭제, 버튼, 사용 불가 |
점검 체크리스트
- 동작을 실행하는 요소는
<button>이고, 페이지 이동은<a href>입니다. - 폼 안의 일반 버튼에는
type="button"을 지정해 의도치 않은 제출을 막았습니다. - Tab으로 모든 버튼에 도달하고, Enter와 Space 둘 다로 실행됩니다.
role="button"을 쓴 경우tabindex="0", Enter/Space 처리, 포커스 표시가 모두 있습니다.- 모든 버튼에 접근 가능한 이름이 있고, 아이콘 버튼은
aria-label등으로 이름을 받았습니다. - 보이는 텍스트가 접근 가능한 이름에 그대로 포함됩니다.
- 토글 버튼은
aria-pressed를"true"/"false"로 명시하고, 레이블은 상태와 상관없이 고정입니다. - 상태가 색으로만 구분되지 않습니다. (테두리, 체크 표시, 굵기 등 다른 단서가 함께 있습니다.)
- 포커스 표시와 버튼 경계가 배경과 3:1 이상 대비됩니다.
- 버튼 실행 후 포커스가 사라지거나 문서 처음으로 튀지 않습니다.
관련 WCAG 성공 기준
- 2.1.1 키보드 (A) — 모든 버튼을 키보드로 실행할 수 있어야 합니다.
- 2.4.7 포커스 표시 (AA) — 어떤 버튼에 포커스가 있는지 눈에 보여야 합니다.
- 1.4.11 비텍스트 대비 (AA) — 버튼 경계·눌림 상태·포커스 표시가 충분히 대비되어야 합니다.
- 2.5.3 레이블을 포함한 이름 (A) — 보이는 레이블이 접근 가능한 이름에 포함되어야 합니다.
- 2.5.8 타깃 크기(최소) (AA) — 버튼의 누르는 영역이 24×24 CSS 픽셀 이상(또는 충분한 간격)이어야 합니다.
- 4.1.2 이름, 역할, 값 (A) — 버튼의 역할, 이름, 눌림 상태가 보조기술에 전달되어야 합니다.