키보드 내비게이션과 포커스 관리 — tabIndex, focus-visible, aria 실무 패턴
마우스 없이도 동작하는 UI를 만들기 위해 알아야 할 tabIndex 규칙, 포커스 트랩, focus-visible, aria 속성 실무 패턴을 정리했다.
키보드 내비게이션과 포커스 관리
키보드로 웹을 탐색하는 사람은 생각보다 많다. 운동 장애가 있는 사용자, 파워 유저, 스크린 리더 사용자 모두 키보드에 의존한다. 접근성을 "나중에 생각할 것"으로 미루면 나중에 고치기 훨씬 어려워진다.
tabIndex 규칙
tabIndex는 Tab 키로 요소에 포커스를 이동시키는 속성이다.
| 값 | 동작 |
|---|---|
| 없음 (기본) | 인터랙티브 요소(a, button, input 등)만 포커스 가능 |
0 | Tab 순서에 포함, DOM 순서를 따름 |
-1 | Tab으로는 접근 불가, JS로만 포커스 가능 |
1 이상 | Tab 순서를 강제 지정 (사용 자제) |
// 커스텀 클릭 가능 요소는 button으로 만드는 것이 우선
<button onClick={handleClick}>클릭</button>
// div로 만들어야 한다면 반드시 tabIndex와 키보드 이벤트 추가
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
>
클릭
</div>tabIndex={1} 이상은 DOM 순서와 다른 Tab 순서를 만들어 혼란을 준다. 쓰지 않는 게 원칙이다.
포커스 트랩 — 모달에서 Tab이 밖으로 나가지 않게
모달이 열렸을 때 Tab을 눌러도 배경 요소에 포커스가 가면 안 된다. 이를 포커스 트랩이라 한다.
function trapFocus(container: HTMLElement) {
const focusable = container.querySelectorAll<HTMLElement>(
'a, button, input, textarea, select, [tabIndex]:not([tabIndex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}React에서는 직접 구현하기보다 @radix-ui/react-focus-trap이나 headless UI 라이브러리를 쓰는 것이 낫다. 엣지 케이스가 많다.
모달이 열릴 때는 모달 내 첫 번째 포커스 가능 요소(또는 모달 컨테이너 자체)로 포커스를 이동시키고, 닫힐 때는 모달을 열었던 트리거 요소로 포커스를 돌려줘야 한다.
function Modal({ isOpen, onClose, triggerRef }) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
} else {
triggerRef.current?.focus(); // 닫힐 때 트리거로 복귀
}
}, [isOpen]);
return isOpen ? (
<div ref={modalRef} tabIndex={-1} role="dialog" aria-modal="true">
{/* ... */}
</div>
) : null;
}focus-visible — 포커스 링을 마우스 클릭 때는 숨기기
마우스로 버튼을 클릭할 때 파란 포커스 링이 보이는 것은 보통 의도하지 않은 동작이다. 그렇다고 outline: none으로 아예 없애면 키보드 사용자가 현재 포커스 위치를 알 수 없다.
/* 나쁜 예 — 키보드 사용자에게도 포커스 링이 사라짐 */
button:focus {
outline: none;
}
/* 좋은 예 — 키보드 탐색 시에만 표시 */
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
button:focus:not(:focus-visible) {
outline: none;
}:focus-visible은 브라우저가 키보드 탐색 중이라고 판단할 때만 적용된다. 마우스 클릭 시에는 적용되지 않는다.
ARIA 속성 실무 패턴
ARIA(Accessible Rich Internet Applications)는 시맨틱 HTML로 표현하기 어려운 UI 상태를 보조 기술에 전달한다.
aria-label / aria-labelledby
시각적 레이블이 없는 요소에 의미를 부여한다.
// 아이콘만 있는 버튼
<button aria-label="검색">
<SearchIcon />
</button>
// 다른 요소의 텍스트를 레이블로 사용
<h2 id="modal-title">삭제 확인</h2>
<div role="dialog" aria-labelledby="modal-title">
{/* ... */}
</div>aria-expanded / aria-controls
토글 가능한 UI 상태를 전달한다.
<button
aria-expanded={isOpen}
aria-controls="menu-list"
onClick={() => setIsOpen(!isOpen)}
>
메뉴
</button>
<ul id="menu-list" hidden={!isOpen}>
{/* ... */}
</ul>aria-live — 동적 콘텐츠 알림
Ajax로 변경된 콘텐츠를 스크린 리더가 자동으로 읽게 한다.
// polite: 현재 읽는 것이 끝난 후 알림
// assertive: 즉시 중단하고 알림 (오류 메시지 등)
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>role
HTML 요소의 의미를 덮어쓰거나 보강한다.
// 탭 UI
<div role="tablist">
<button role="tab" aria-selected={activeTab === 0} aria-controls="panel-0">탭 1</button>
<button role="tab" aria-selected={activeTab === 1} aria-controls="panel-1">탭 2</button>
</div>
<div role="tabpanel" id="panel-0" hidden={activeTab !== 0}>...</div>
<div role="tabpanel" id="panel-1" hidden={activeTab !== 1}>...</div>체크리스트
- 모든 인터랙티브 요소가 Tab으로 접근 가능한가
div/span에 클릭 핸들러가 있다면role과 키보드 이벤트가 있는가outline: none을 쓰고 있다면:focus-visible로 대체했는가- 모달이 열릴 때 포커스가 이동하고, 닫힐 때 원래 위치로 돌아오는가
- 아이콘 버튼에
aria-label이 있는가 - 동적으로 변경되는 상태 메시지에
aria-live가 있는가
Related Posts
같이 읽으면 좋은 글
스크린 리더를 직접 써보고 알게 된 것들
VoiceOver와 NVDA를 직접 사용해보면서 개발자가 놓치기 쉬운 접근성 문제들을 정리했다. 코드로 보던 것과 실제로 들리는 것은 다르다.
프론트엔드는 이미 온톨로지를 쓰고 있다
메타태그, 접근성, 상태 관리, GraphQL. 프론트엔드가 매일 쓰는 것들을 온톨로지 관점에서 다시 읽으면 '왜 이렇게 생겼는지'가 설명된다. 이 블로그의 실제 코드로 확인했다.
택소노미에서 온톨로지로 — 블로그 정보 구조를 다시 보기
카테고리만으로는 글이 쌓일수록 한계가 온다. 카테고리·태그·관계 이름표를 3단계로 놓고, 이 블로그가 지금 어디까지 와 있고 다음이 무엇인지 정리했다.