컴포넌트 패턴
알림 메시지(토스트·상태)
포커스를 옮기지 않고 결과·경고·새 소식을 알리는 상태 메시지를 status, alert, log 역할로 구분해 만들고, 잠깐 떴다 사라지는 토스트를 접근성 있게 구현하는 방법입니다.
언제 쓰나요
“저장되었습니다”, “검색 결과 12건”, “연결이 끊어졌습니다”처럼 사용자의 포커스를 옮기지 않고 화면 일부만 바뀌어 결과를 알려야 할 때 씁니다. 화면을 보는 사용자는 바뀐 문구를 눈으로 확인하지만, 화면낭독기 사용자는 그 부분이 라이브 리전(내용이 바뀌면 자동으로 읽어 주는 영역)이어야만 알 수 있습니다. WCAG에서는 이런 메시지를 상태 메시지라고 부릅니다.
메시지의 성격에 따라 역할을 고릅니다.
| 역할 | 암묵적 aria-live | 암묵적 aria-atomic | 용도 | 예 |
|---|---|---|---|---|
status | polite | true | 급하지 않은 결과·진행 상황. 읽던 내용을 마친 뒤 알립니다. | 저장 완료, 장바구니 담김, 검색 결과 개수 |
alert | assertive | true | 중요하고 시간에 민감한 정보. 읽던 내용을 끊고 바로 알립니다. | 세션 만료 임박, 연결 끊김, 결제 실패 |
log | polite | (기본값 false) | 순서대로 쌓이는 기록. 새로 추가된 항목만 알립니다. | 채팅, 작업 기록, 콘솔 출력 |
<output>요소는 암묵적으로status역할을 가집니다. 계산 결과처럼 폼과 관련된 결과에 쓸 수 있습니다.alert는 사용자를 방해하므로 꼭 필요한 경우에만 씁니다. 대부분의 알림은status로 충분합니다.- 사용자가 반드시 응답해야 하는 경고(예: “저장하지 않고 나갈까요?”)는 라이브 리전이 아니라 포커스를 옮기는
alertdialog(대화상자)로 만듭니다.
구조
- 라이브 리전은 페이지가 처음 그려질 때부터 DOM에 있어야 합니다. 보조기술은 이미 등록된 영역의 변화를 감지하므로, 메시지와 영역을 동시에 삽입하면 읽히지 않는 경우가 많습니다. 빈 영역을 미리 두고 텍스트만 넣습니다.
- 영역 자체를
display: none이나hidden으로 숨긴 상태에서 내용을 넣으면 읽히지 않습니다. 보이지 않게 하려면sr-only같은 시각적 숨김을 씁니다. - 라이브 리전에는 짧은 텍스트만 넣습니다. 버튼 같은 조작 요소가 함께 있으면 버튼 이름까지 읽혀 혼란스럽습니다. 토스트라면 메시지 텍스트 부분만 라이브 리전으로 만들고, 버튼은 그 밖에 둡니다.
- 같은 문구를 다시 알려야 할 때는 내용을 비운 뒤 잠시 후 다시 넣습니다. 같은 텍스트로 바꾸면 변화가 없어 읽히지 않습니다.
log에서 오래된 항목을 지워도 기본값(aria-relevant="additions text")에 따라 삭제는 읽히지 않습니다.
<!-- 페이지를 열 때부터 빈 상태로 존재 -->
<div role="status" id="save-status"></div>
<div role="alert" id="net-alert"></div>
<div role="log" aria-label="채팅 메시지" id="chat"></div>
<!-- 토스트: 메시지 부분만 라이브 리전 -->
<div class="toast" id="toast" hidden>
<p role="status" id="toast-msg"></p>
<button type="button">실행 취소</button>
<button type="button" aria-label="알림 닫기">×</button>
</div>토스트 접근성
토스트(스낵바)는 화면 구석에 잠깐 나타났다 사라지는 알림입니다. 보기에는 간단하지만 다음 문제를 자주 일으킵니다.
- 포커스를 빼앗지 않습니다. 토스트가 뜰 때 포커스를 토스트로 옮기면 사용자가 하던 작업이 끊깁니다. 라이브 리전으로 알리기만 합니다.
- 충분한 시간을 줍니다. 몇 초 뒤 저절로 사라지면 읽는 속도가 느리거나 화면을 확대한 사용자는 놓칩니다. “실행 취소”처럼 조작할 버튼이 있거나 중요한 토스트는 사용자가 닫을 때까지 남겨 둡니다. 자동으로 닫더라도 마우스를 올리거나 포커스가 있으면 멈추고, 같은 정보를 다른 곳(알림함 등)에서도 확인할 수 있게 합니다.
- 키보드로 닿을 수 있어야 합니다. 토스트 안의 버튼은 Tab으로 도달할 수 있어야 합니다. DOM에서 토스트를 페이지 맨 끝에 두면 키보드 사용자가 찾아가기 어려우므로, 트리거 가까이에 두거나 같은 기능을 다른 경로(예: 메뉴의 “실행 취소”, 단축키)로도 제공합니다.
- 닫을 수 있어야 합니다. 이름이 있는 닫기 버튼을 두고, 포커스가 토스트 안에 있을 때 Esc로도 닫을 수 있게 하면 편리합니다. 닫을 때 포커스가 토스트 안에 있었다면 원래 위치(트리거)로 되돌립니다.
키보드 동작
라이브 리전은 키보드 조작이 없습니다. 조작 버튼이 있는 토스트는 다음과 같이 동작하게 만듭니다. (APG에 토스트 패턴은 따로 없으며, 아래는 권장 구현입니다.)
| 키 | 동작 |
|---|---|
| Tab | 트리거 다음에 토스트 안의 버튼(실행 취소, 닫기)으로 이동합니다. 토스트가 떠도 포커스는 그대로입니다. |
| Enter / Space | 토스트 안의 버튼을 실행합니다. |
| Esc | 포커스가 토스트 안에 있을 때 토스트를 닫고, 포커스를 트리거로 되돌립니다. |
필요한 ARIA
| 역할·속성 | 붙이는 곳 | 설명 |
|---|---|---|
status | 결과 메시지 영역, 토스트 메시지 | 정중하게(polite) 알리는 상태 메시지 영역입니다. |
alert | 긴급 경고 영역 | 즉시(assertive) 알립니다. 포커스는 옮기지 않습니다. |
log | 채팅·기록 영역 | 추가된 항목만 순서대로 알립니다. aria-label로 영역 이름을 줍니다. |
aria-live | 역할이 맞지 않는 일반 영역 | polite / assertive. 위 역할을 쓰면 암묵적으로 정해지므로 보통 따로 쓰지 않습니다. |
aria-atomic | 라이브 리전 | true면 바뀐 부분만이 아니라 영역 전체를 읽습니다. status, alert는 기본값이 true입니다. |
aria-relevant | 라이브 리전 | 어떤 변화(추가·삭제·텍스트)를 알릴지 정합니다. 기본값 additions text를 거의 그대로 씁니다. |
alertdialog | 응답이 필요한 경고 | 라이브 리전 대신 포커스를 옮기는 모달 대화상자로 만듭니다. |
role과 aria-live를 겹쳐 쓰지 마세요
role="alert"에 aria-live="polite"를 더하는 식으로 성격이 다른 값을 섞으면 역할의 암묵적 값을 덮어써 의도가 흐려집니다. 일부 오래된 브라우저 호환을 위해 role="status" aria-live="polite"처럼 같은 값을 함께 쓰는 것은 괜찮습니다.
직접 해 보기
화면낭독기를 켜고 버튼을 눌러 보세요. 포커스는 버튼에 그대로 있고, 각 영역의 내용만 바뀝니다. 왼쪽 잘못된 구현은 메시지가 뜰 때 라이브 리전을 새로 만들기 때문에 읽히지 않는 경우가 많고, 2초 만에 사라집니다.
잘못된 구현
누를 때마다 aria-live 영역이 새로 생겨 등록되기 전에 내용이 들어가고, 2초 뒤 사라져 읽을 시간도 부족합니다.
올바른 구현
상태 메시지
경고
채팅
올바른 구현: 토스트
- 회의록.docx
- 예산안.xlsx
- 발표.pptx
토스트는 버튼 바로 뒤에 있어 Tab으로 이동할 수 있고, 닫기 전까지 사라지지 않습니다. 토스트 안에서 Esc를 누르면 닫힙니다.
실시간 코드
토스트 메시지 영역과 hidden
위 데모의 토스트 메시지 영역(role="status")은 토스트 상자가 hidden인 동안에도 DOM에 남아 있다가, 상자를 보이게 한 직후 텍스트를 채웁니다. 숨김을 푸는 것과 동시에 텍스트를 넣으면 일부 화면낭독기에서 읽히지 않을 수 있어, 데모에서는 텍스트를 잠시 뒤에 넣습니다. 가장 확실한 방법은 상자를 항상 보이게 두고(빈 상태에서는 크기 0) 텍스트만 바꾸는 것입니다.
전체 코드
<script>
var t = document.createElement('div');
t.setAttribute('aria-live', 'polite');
t.textContent = '저장되었습니다.';
document.body.appendChild(t); // 등록되기 전에 내용이 들어감
setTimeout(function () { t.remove(); }, 2000); // 너무 빨리 사라짐
</script><div role="status" id="save-status"></div>
<script>
var status = document.getElementById('save-status');
status.textContent = '저장되었습니다.';
</script><div role="status" id="save-status" class="sr-only"></div>
<div role="alert" id="net-alert"></div>
<div role="log" aria-labelledby="chat-title" id="chat"></div>
<button type="button" id="delete">첫 파일 삭제</button>
<div class="toast" id="toast" hidden>
<p role="status" id="toast-msg"></p>
<button type="button" id="undo">실행 취소</button>
<button type="button" id="close" aria-label="알림 닫기">×</button>
</div>// 같은 문구도 다시 읽히도록 비운 뒤 잠시 후 채움
function announce(el, text) {
el.textContent = '';
setTimeout(function () { el.textContent = text; }, 100);
}
announce(document.getElementById('save-status'), '임시 저장되었습니다.');
announce(document.getElementById('net-alert'), '서버와 연결이 끊어졌습니다.');
// log: 항목을 추가만 함
var p = document.createElement('p');
p.textContent = '민지: 회의 자료 올렸어요.';
document.getElementById('chat').appendChild(p);
// 토스트: 포커스는 옮기지 않고, 닫을 때까지 유지
var toast = document.getElementById('toast');
var msg = document.getElementById('toast-msg');
var trigger = document.getElementById('delete');
function showToast(text) {
toast.hidden = false;
announce(msg, text);
}
function closeToast() {
var hadFocus = toast.contains(document.activeElement);
toast.hidden = true;
msg.textContent = '';
if (hadFocus) trigger.focus(); // 포커스를 잃지 않게 되돌림
}
trigger.addEventListener('click', function () {
// ...파일 삭제...
showToast('회의록.docx 파일을 삭제했습니다.');
});
document.getElementById('undo').addEventListener('click', function () {
// ...되살리기...
announce(msg, '회의록.docx 파일을 되살렸습니다.');
});
document.getElementById('close').addEventListener('click', closeToast);
toast.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeToast();
});자동으로 닫는 토스트가 꼭 필요하다면
조작 버튼이 없는 단순 정보 토스트를 자동으로 닫는다면, 읽을 시간을 넉넉히 주고(문구 길이에 비례), 마우스를 올리거나 포커스가 들어오면 타이머를 멈추며, 같은 정보를 다른 곳에서 다시 확인할 수 있게 합니다. 사용자가 시간 제한을 끄거나 늘릴 수 있는 설정을 두는 것도 방법입니다.
화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| “임시 저장” 누름 (status) | 임시 저장 (status), 버튼뒤에 이어서 임시 저장되었습니다. |
| “연결 끊김” 누름 (alert) | 읽던 내용을 끊고 서버와 연결이 끊어졌습니다. …(일부 화면낭독기는 앞에 “경고”를 붙임) |
| “새 채팅” 누름 (log) | 민지: 회의 자료 올렸어요.(새 항목만) |
| “첫 파일 삭제” 누름 (토스트) | 회의록.docx 파일을 삭제했습니다.(포커스는 버튼에 그대로) |
| Tab으로 토스트 버튼 이동 | 실행 취소, 버튼→ 알림 닫기, 버튼 |
| 잘못된 구현에서 “저장하기” | 아무 안내도 없는 경우가 많음 |
점검 체크리스트
- 포커스를 옮기지 않는 결과·경고 메시지가 라이브 리전(
status,alert,log)으로 전달됩니다. - 메시지 성격에 맞는 역할을 골랐고,
alert는 긴급한 경우에만 씁니다. - 라이브 리전이 페이지 로드 시점부터 DOM에 있고, 내용만 바꿉니다.
- 라이브 리전이
display: none/hidden상태에서 갱신되지 않습니다. - 라이브 리전에는 짧은 텍스트만 있고, 버튼 같은 조작 요소는 그 밖에 있습니다.
- 토스트가 떠도 포커스가 이동하지 않습니다.
- 조작 버튼이 있거나 중요한 토스트는 사용자가 닫을 때까지 유지되고, 자동으로 닫히는 토스트는 시간이 충분하며 호버·포커스 시 멈춥니다.
- 토스트 안의 버튼에 키보드로 도달할 수 있고, 같은 기능을 다른 경로로도 쓸 수 있습니다.
- 토스트를 닫을 때 포커스가 토스트 안에 있었다면 의미 있는 위치로 되돌립니다.
- 응답이 필요한 경고는 라이브 리전이 아니라
alertdialog로 만들었습니다.
관련 WCAG 성공 기준
- 4.1.3 상태 메시지 (AA): 포커스를 받지 않는 상태 메시지를 보조기술이 알 수 있게 합니다.
- 2.2.1 타이밍 조절 가능 (A): 자동으로 사라지는 토스트에 충분한 시간과 조절 방법을 줍니다.
- 2.1.1 키보드 (A): 토스트 안의 조작을 키보드로 할 수 있습니다.
- 2.4.3 포커스 순서 (A): 알림이 포커스를 빼앗거나 잃게 만들지 않습니다.
- 2.2.4 방해 (AAA): 긴급하지 않은 알림으로 사용자를 방해하지 않습니다.
- 1.3.1 정보와 관계 (A)