lifecarelog
프론트엔드

라이브러리 없이 모달 focus trap 구현, 접근성 함정 3개

모달 focus trap을 직접 구현하며 만난 숨은 요소 수집, 포커스 복귀 누락, 스크린리더 가상 커서 함정 3개와 해결 방법을 정리했어요.

4분 읽기

모달을 띄우고 Tab 키를 눌렀는데 포커스가 배경으로 새어 나간 적 없나요? 매번 라이브러리를 설치하다가 이번엔 focus trap을 직접 만들어 봤어요. 코드는 30줄 정도로 끝났는데, 진짜 배운 건 그 과정에서 밟은 접근성 함정 3개였어요.

뼈대는 단순해요

모달이 열리면 첫 포커스 요소로 이동시키고, Tab과 Shift+Tab 2가지 경계에서 포커스를 반대편으로 되돌리면 돼요. 포커스 가능한 요소는 태그와 속성 6종을 셀렉터로 모았어요.

const focusable = modal.querySelectorAll(
  'a[href], button, input, select, textarea, [tabindex]'
);

여기까지 직접 만들어 보니 20분이 안 걸렸어요. 문제는 이다음부터였죠.

모달 열기첫 요소 포커스Tab 순환닫기트리거 버튼으로 복귀

함정 1. 셀렉터에 잡힌다고 다 포커스되진 않아요

display:none으로 숨긴 버튼, disabled 입력창, tabindex가 -1인 요소도 셀렉터에는 걸려요. 숨은 요소가 목록 끝에 있으면 focus() 호출이 조용히 실패해서 순환이 어긋나요. 돌려 보니 Tab이 갑자기 모달 밖으로 튀던 원인이 이거였어요. offsetParent가 null인 요소와 disabled를 걸러내는 필터 한 줄로 잡았어요.

함정 2. 닫은 다음이 진짜예요

모달을 닫으면 포커스가 body로 날아가요. 키보드 사용자는 페이지 맨 위에서부터 다시 Tab을 눌러야 하죠. 열기 직전의 document.activeElement를 저장해 두고 닫을 때 되돌리는 코드 2줄이면 해결돼요. 저는 이걸 빼먹은 채 한참을 지나쳤는데, 마우스만 쓰면 전혀 눈치챌 수 없었어요.

함정 3. Tab만 막으면 끝인 줄 알았어요

스크린리더는 Tab 키로만 움직이지 않아요. 가상 커서는 focus trap과 무관하게 배경 콘텐츠를 그대로 읽어요. VoiceOver로 테스트해 보면 모달 뒤 텍스트가 읽히는 걸 바로 확인할 수 있어요. 모달에 role="dialog"와 aria-modal="true"를 붙이고, 배경 컨테이너에 inert 속성을 주니 확실해졌어요.

  • 숨은 요소 걸러내기
  • 닫을 때 포커스 복귀
  • aria-modal과 inert

마무리

저도 플랜비(plan-b) 같은 서비스를 혼자 운영하면서 모달 만들 일이 계속 생기는데, 이 30줄을 한 번 정리해 두니 반복이 줄었어요. 접근성은 추가 기능이 아니라 기본값에 가까워요. 다음 모달을 만들면 마우스를 놓고 Tab 키부터 한 바퀴 돌려 보세요. 함정은 거기서부터 보이기 시작해요.

#접근성#focus-trap#모달#프론트엔드

라이프케어로그 서비스가 궁금하신가요?

AI 기반 건강·일정·재활 관리 앱을 직접 써보세요.

서비스 살펴보기

관련 글

댓글

아직 댓글이 없어요. 첫 댓글을 남겨주세요.