프론트엔드 개발을 하다 보면 모달 컴포넌트는 정말 자주 등장한다.
알림, 확인창, 폼 입력 등 다양한 UX에서 빠질 수 없는 요소 중 하나다.
하지만 그동안 여러 모달을 구현하면서, 보기에 자연스러운 모달에만 집중해 왔고, 키보드 사용자나 스크린 리더 사용자에게
이 모달이 어떤 경험으로 다가올지는 깊게 고민하지 못했던 것 같다.
코테이토 동아리에서 진행한 아티클 스터디를 통해 DEVOCEAN의「시각장애인을 위해 모달 컴포넌트 접근성 개선하기」 글을 읽게 되었고, 이를 계기로 실제 프로젝트에 적용해 보며 느낀 점과 구현 과정을 정리해보려 한다.
왜 모달에서 접근성이 중요한가?
일반 사용자는 마우스로 모달을 클릭하고 닫을 수 있지만, 시각장애인이나 키보드 사용자들은 Tab, shift + tab 을 통해 HTML 요소를 순차적으로 탐색한다.
이때 접근성을 고려하지 않은 모달은 다음과 같은 문제를 만들 수 있다.
- 모달이 열렸는지 인지하기 어렵다.
- 닫기 버튼까지 가기 위해 여러 번 Tab을 눌러야 한다.
- 모달이 떠 있는데도 불구하고 뒤에 있는 페이지 요소로 포커스가 이동된다.
모달 접근성 목표
현재 진행하고 있던 프로젝트인 '블록체인 기반 아이디어 저장, 공증 플랫폼 iDear'에서는 여러 번의 확인 모달이 뜨게 된다.
여러 모달을 공통 래퍼로 묶기 위해 ModalWrapper.tsx 라는 컴포넌트를 별도로 분리하여 관리하고 있었는데, 이 래퍼 컴포넌트에 모달 접근성을 개선하기 위한 다음 4가지의 목표를 잡았다.
1. 스크린 리더가 '이건 모달이다'라고 인식할 수 있을 것
2. Esc 키로 언제든 모달을 닫을 수 있을 것
3. 모달이 열려 있는 동안 포커스가 외부로 빠져나가지 않을 것
4. 모달이 열릴 때, 의미 있는 위치로 자동 포커싱 될 것
ARIA 속성으로 모달 의미 명확히 하기
가장 기본적으로 접근성을 개선하기 위해 ARIA 속성들을 추가한다.
<motion.div
aria-modal="true"
role="dialog"
aria-labelledby="modal-header"
aria-describedby="modal-description"
>
- role='dialog' : 스크린 리더에게 이 요소가 대화상자(모달)임을 명시한다.
- aria-modal='true' : 현재 UI 흐름이 모달에 갇혀 있음을 전달한다.
- aria-labelledby, aria-describedby : 모달의 제목과 설명을 음성으로 자연스럽게 읽어 준다.
이 속성을 적용함으로써 스크린 리더 사용자에게 '지금 ** 모달에 진입했다.' 라는 명확한 컨텍스트를 제공할 수 있게 된다.
ESC 키로 모달 닫기
키보드 사용자에게 닫기 버튼까지 이동하라는 것은 꽤 부담스러운 요구일 수 있다.
그래서 Esc 키로 모달을 닫을 수 있도록 이벤트 핸들러를 추가한다.
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose?.();
}
};
if (isOpen) {
window.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
}
return () => {
window.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
- 모달이 열릴 때 이벤트 등록
- 닫힐 때 body scroll 복원
Focus Trap으로 포커싱 가두기
아무리 ARIA 속성을 잘 설정해도, Tab 키를 눌렀을 때 모달 뒤의 헤더나 링크로 이동한다면 사용자는 지금 보고 있는 UI를 신뢰할 수 없게 된다.
이를 해결하기 위해 focus-trap-react 라이브러리를 적용했다.
focus trap 라이브러리란?
focus-trap은 포커스를 특정 영역 내에서만 유지하도록 하여, 사용자가 키보드로 탐색할 때 혼란을 줄인다.
여러 개의 포커스 가능한 요소가 있는 경우에 유용하며, 사용자가 의도치 않은 포커싱 이동을 방지한다.
모달처럼 현재 맥락에서 벗어나면 안 되는 UI에 특히 유용하다.
<FocusTrap
focusTrapOptions={{
onActivate: () => {
const focusable = document.querySelector('[data-auto-focus]');
if (focusable instanceof HTMLElement) focusable.focus();
},
escapeDeactivates: true,
clickOutsideDeactivates: true,
}}
>
<motion.div>
{children}
</motion.div>
</FocusTrap>
적용 효과
- Tab / Shift + tab -> 모달 내부에서만 순환
- Esc -> 포커스 트랩 해제 + 모달 닫힘
- 최초 진입 시 의미 있는 버튼에 자동 포커싱(data-auto-focus를 이용한 초기 포커싱)
프로젝트에 적용한 결과

모달이 렌더링된 상태에서 Tab 키를 연속으로 눌러도 포커스가 외부 요소로 이동하지 않았고, 초기 포커싱 또한 ‘확인’ 버튼과 같이 의미 있는 요소로 정확히 설정된 것을 확인할 수 있다.
ModalWrapper.tsx 컴포넌트의 전체 코드는 다음과 같다.
(framer-motion 을 이용한 애니메이션 처리가 포함되어 있다.)
'use client';
import {ReactNode, useEffect} from 'react';
import {createPortal} from 'react-dom';
import {motion, AnimatePresence} from 'framer-motion';
import {FocusTrap} from 'focus-trap-react';
interface ModalWrapperProps {
children: ReactNode;
isOpen: boolean;
onClose?: () => void;
}
/**
* ModalWrapper
* - 배경(overlay), 애니메이션, 포털 렌더링을 담당하는 공통 모달 래퍼입니다.
* - 내부에 어떤 모달 컴포넌트든 children으로 전달해 사용할 수 있습니다.
* - esc 키, overlay 클릭 시 모달이 닫힙니다.
*/
export const ModalWrapper = ({
children,
isOpen,
onClose,
}: ModalWrapperProps) => {
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose?.();
}
};
if (isOpen) {
window.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
}
return () => {
window.removeEventListener('keydown', handleEsc);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (typeof window === 'undefined') return null;
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className='fixed inset-0 z-50 flex items-center justify-center bg-black/20'
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
aria-modal='true'
role='dialog'
aria-labelledby='modal-header'
aria-describedby='modal-description'
onClick={onClose}>
<FocusTrap
focusTrapOptions={{
onActivate: () => {
const focusable = document.querySelector('[data-auto-focus]');
if (focusable instanceof HTMLElement) focusable.focus();
},
escapeDeactivates: true,
clickOutsideDeactivates: true,
}}>
<motion.div
onClick={(e) => e.stopPropagation()}
initial={{scale: 0.95, opacity: 0}}
animate={{scale: 1, opacity: 1}}
exit={{scale: 0.95, opacity: 0}}
transition={{duration: 0.2}}>
{children}
</motion.div>
</FocusTrap>
</motion.div>
)}
</AnimatePresence>,
document.body
);
};
마무리
이번 경험을 통해 접근성은 사용자 흐름을 강제로 끊는 UI일수록 반드시 고려해야 할 요소라는 생각이 들었다.
특히 모달처럼 사용자의 주의를 한곳으로 모으는 컴포넌트일수록 접근성을 놓치면 혼란은 배로 커질 수 있다.
앞으로 새로운 컴포넌트를 구현할 때 “이걸 키보드로만 사용해도 자연스러울까?” 라는 질문을 한 번 더 던져보는 습관을 가져보려 한다.
모달 컴포넌트를 구현하고 있다면 웹 접근성을 고려하는 것을 잊지 말자!
'프론트엔드' 카테고리의 다른 글
| 모노레포에서 미디어 쿼리가 무시되는 이유 (0) | 2026.03.02 |
|---|---|
| @use-funnel로 복잡한 온보딩 흐름 제어하기 (feat. Zod) (1) | 2026.02.23 |
| 프로젝트 통합으로 생산성 높이기: Turborepo 도입 및 모노레포 마이그레이션 여정 (0) | 2026.02.22 |
| Next.js 프로젝트에서 Storybook 도입해보기 (0) | 2026.01.29 |
| Next.js App router 프로젝트 구조 활용 전략 (1) | 2026.01.29 |