적용 방법
상태 동기화
화면에 보이는 상태와 aria-* 상태 값이 항상 같도록 유지하는 방법입니다. CSS 속성 선택자로 스타일과 상태를 묶는 기법, React·Vue에서 상태를 바인딩하는 예시를 다룹니다.
상태 동기화란
ARIA 상태(state)는 위젯의 현재 상황을 보조기술에 알립니다. aria-expanded는 펼침/접힘, aria-pressed는 토글 버튼의 눌림, aria-checked는 체크 여부, aria-selected는 선택 여부, aria-current는 현재 위치, aria-invalid는 입력 오류를 나타냅니다.
이 값은 HTML에 한 번 적어 두면 끝나는 것이 아니라, 사용자가 조작할 때마다 화면의 변화와 함께 바뀌어야 합니다. 메뉴가 열렸는데 aria-expanded="false"가 그대로라면 화면낭독기는 “축소됨”이라고 알리고, 사용자는 메뉴가 열리지 않았다고 판단합니다. 화면과 다른 상태를 알리는 것은 상태를 알리지 않는 것보다 나쁩니다(WCAG 4.1.2 이름, 역할, 값은 상태 변화가 보조기술에 전달될 것을 요구합니다).
<button type="button" aria-expanded="false" aria-controls="faq-1">배송은 얼마나 걸리나요?</button>
<div id="faq-1" hidden>주문 후 2~3일 안에 도착합니다.</div>btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', String(open)); // 상태
panel.hidden = !open; // 화면
});어긋나기 쉬운 경우
- 클래스만 바꿈:
.is-open클래스로 화면만 바꾸고aria-expanded는 갱신하지 않습니다. - 다른 경로로 닫힘: 메뉴를 버튼으로 열고 닫을 때는 맞지만,
Esc키·바깥 클릭·다른 메뉴 열기·화면 크기 변경으로 닫힐 때는 상태를 되돌리지 않습니다. - 초기값 불일치: 서버에서 “펼친 상태”로 렌더링했는데 HTML의
aria-expanded는false입니다. - 여러 요소가 같은 패널을 제어: 한 버튼만 갱신하고 다른 버튼은 옛 상태로 남습니다.
- 상태를 이름에 넣음: “메뉴 열기” → “메뉴 닫기”로 이름을 바꾸면서
aria-expanded까지 함께 써서 “메뉴 닫기, 버튼, 확장됨”처럼 정보가 겹칩니다. 이름 변경과 상태 속성 중 하나만 씁니다. - 값을 잘못된 형식으로 씀:
aria-expanded="yes",aria-checked="1"처럼 정해진 토큰이 아닌 값을 쓰면 무시되거나 기본값으로 처리됩니다. 문자열"true"/"false"(속성에 따라"mixed")를 씁니다.
btn.addEventListener('click', function () {
menu.classList.toggle('is-open');
});function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
menu.hidden = !open;
}
btn.addEventListener('click', function () {
setOpen(btn.getAttribute('aria-expanded') !== 'true');
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); btn.focus(); }
});상태는 한 곳에서 관리하기
동기화 버그를 막는 가장 확실한 방법은 상태를 하나의 출처에서만 바꾸고, 화면 표시와 ARIA 값을 모두 그 출처에서 만들어 내는 것입니다. 위 예의 setOpen()처럼 상태를 바꾸는 함수를 하나만 두고, 버튼 클릭·Esc·바깥 클릭이 모두 그 함수를 호출하게 합니다.
가능하면 상태를 내장한 네이티브 요소를 쓰는 것도 좋은 방법입니다. 브라우저가 상태를 자동으로 동기화하기 때문입니다.
| 필요한 상태 | ARIA | 네이티브 대안 |
|---|---|---|
| 펼침/접힘 | aria-expanded | <details>/<summary>의 open |
| 체크 | aria-checked | <input type="checkbox">의 checked, indeterminate |
| 선택 | aria-selected | <select>/<option>의 selected |
| 사용 불가 | aria-disabled | disabled 속성 |
| 필수 | aria-required | required 속성 |
| 모달 열림 | aria-modal | <dialog>의 showModal() |
CSS 속성 선택자로 스타일과 상태 묶기
스타일을 클래스 대신 ARIA 속성 선택자에 걸면, ARIA 값을 바꾸지 않는 한 화면도 바뀌지 않습니다. 개발자가 상태 갱신을 잊으면 화면에서 바로 드러나므로 동기화 버그가 생길 여지가 줄어듭니다.
/* 펼침 아이콘 회전: aria-expanded 값에 묶음 */
.disclosure-btn[aria-expanded="true"] .chevron { transform: rotate(180deg); }
/* 버튼 바로 뒤 패널: 상태가 true일 때만 보임 */
.disclosure-btn[aria-expanded="false"] + .panel { display: none; }
/* 토글 버튼 눌림 */
button[aria-pressed="true"] { background: #1a5fd0; color: #fff; }
/* 현재 페이지 링크 */
nav a[aria-current="page"] { font-weight: 700; border-bottom: 3px solid; }
/* 선택된 탭 */
[role="tab"][aria-selected="true"] { border-bottom: 3px solid currentColor; }
/* 오류 필드 */
input[aria-invalid="true"] { border-color: #c62828; }색만으로 상태를 구분하지 마세요
선택·눌림·오류 상태를 색 차이만으로 표시하면 색각 이상 사용자나 고대비 모드 사용자가 알아보기 어렵습니다(WCAG 1.4.1 색의 사용). 굵기·밑줄·아이콘·테두리처럼 모양이 바뀌는 표시를 함께 쓰고, 상태 표시는 주변과 3:1 이상 대비를 유지합니다(WCAG 1.4.11).
[aria-expanded="false"] + .panel { display: none; }처럼 형제 선택자로 패널을 숨기는 방식은 HTML 구조(버튼 바로 뒤에 패널)에 의존하므로, 구조가 다르면 스크립트로 hidden을 함께 바꿉니다.
직접 해 보기
두 버튼을 눌러 비교해 보세요. 왼쪽은 클래스만 바꾸고, 오른쪽은 aria-expanded를 바꾸며 화면은 CSS 속성 선택자가 따라 바꿉니다. 실시간 코드에서 두 버튼의 속성 차이를 확인할 수 있습니다.
화면낭독기 안내 예시가 여기에 표시됩니다.
실시간 코드
프레임워크에서 상태 바인딩
React·Vue 같은 선언형 프레임워크는 상태 변수 하나로 화면과 속성을 함께 만들어 내므로 동기화가 쉽습니다. ARIA 값도 같은 상태 변수에서 계산하도록 바인딩합니다.
function Disclosure({ title, children }) {
const [open, setOpen] = useState(false);
const panelId = useId();
return (
<>
<button type="button"
aria-expanded={open} // React는 "true"/"false" 문자열로 출력
aria-controls={panelId}
onClick={() => setOpen(o => !o)}>
{title}
</button>
<div id={panelId} hidden={!open}>{children}</div>
</>
);
}<script setup>
import { ref, useId } from 'vue';
const open = ref(false);
const panelId = useId(); // Vue 3.5 이상
</script>
<template>
<button type="button"
:aria-expanded="String(open)"
:aria-controls="panelId"
@click="open = !open">
배송 안내
</button>
<div :id="panelId" v-show="open">주문 후 2~3일 안에 도착합니다.</div>
</template>- 문자열로 출력되는지 확인: React는
aria-*속성의 불리언을"true"/"false"문자열로 출력합니다. Vue 3도 불리언 속성이 아닌 속성의false를"false"로 출력하지만, 버전과 상황에 따라 다를 수 있어String(open)처럼 명시하면 안전합니다.null/undefined를 넣으면 속성 자체가 빠집니다. - 속성을 빼야 할 때와 false를 둘 때 구분:
aria-pressed가 없으면 일반 버튼,"false"면 “눌리지 않은 토글 버튼”입니다. 토글 버튼이라면 초기값도"false"로 명시합니다. - ID 생성:
aria-controls,aria-labelledby,aria-describedby에 쓸 ID는 컴포넌트를 여러 번 써도 겹치지 않게 ReactuseId(), VueuseId()같은 기능으로 만듭니다. - DOM에서 사라지는 패널: 조건부 렌더링(
{open && <div>},v-if)으로 패널을 제거하면aria-controls가 없는 ID를 가리키게 됩니다. 패널을 남겨 두고hidden/v-show로 숨기거나, 닫힌 동안에는aria-controls를 빼는 방법을 씁니다. - 컴포넌트 라이브러리 확인: UI 라이브러리의 컴포넌트가 상태 속성을 올바로 갱신하는지 렌더링된 HTML에서 확인합니다(동적 콘텐츠와 SPA 참고).
점검 방법
- 위젯을 마우스·키보드·
Esc·바깥 클릭 등 모든 경로로 열고 닫으며, 개발자 도구 Elements 패널에서 ARIA 값이 함께 바뀌는지 봅니다. - 브라우저 접근성 패널(Chrome·Edge Accessibility 탭, Firefox 접근성 검사기)에서 계산된 상태(expanded, pressed, checked)를 확인합니다.
- 화면낭독기로 조작해 상태 변화가 즉시 안내되는지(“확장됨”, “눌림”, “선택됨”) 확인합니다.
- 페이지를 새로 고친 직후의 초기 상태가 화면과 맞는지 확인합니다.
관련 문서
aria-expanded · aria-pressed · aria-checked · aria-selected · aria-current · 디스클로저 패턴 · 아코디언 패턴 · 자주 하는 ARIA 실수