Dialog, Sheet, Popover: 오버레이 컴포넌트의 접근성과 포커스 관리

고객이 이메일을 보내왔습니다. “웹사이트에서 오버레이를 연 뒤 Tab 키를 누르면 포커스가 배경 페이지로 빠져나갑니다. 키보드 사용자는 아예 조작할 수가 없어요.”
조금 민망한 일이었습니다. 이 오버레이는 바로 지난주에 작성한 것이었기 때문입니다.
코드를 열어 보니 문제는 명확했습니다. Dialog가 열린 뒤에도 포커스가 배경의 버튼에 그대로 남아 있었고, 사용자가 Tab 키를 누르면 당연히 배경 페이지로 이동했습니다. 스크린 리더 사용자는 상황이 더 나빴습니다. 포커스가 Dialog 안으로 이동하지 않았고 ARIA 속성도 설정되지 않아 오버레이가 열렸다는 사실조차 알 수 없었습니다.
이번 글에서는 Dialog, Sheet, Popover라는 세 가지 오버레이 컴포넌트의 접근성과 포커스 관리를 살펴보겠습니다. 제가 겪었던 시행착오가 여러분에게는 도움이 되기를 바랍니다.
먼저 알아둘 것: 세 컴포넌트의 핵심 차이
솔직히 저를 포함한 많은 개발자가 이 세 컴포넌트의 차이를 명확하게 구분하지 못합니다. 모두 화면 위에 뜨는 레이어이니 비슷하다고 생각하기 쉽습니다. 하지만 실제로는 이 핵심 차이가 접근성을 처리하는 방식을 결정합니다.
Dialog(모달 대화상자)
Dialog는 배경과의 상호작용을 완전히 차단하는 모달 오버레이입니다.
예를 들어 주문 삭제 버튼을 클릭하면 확인 대화상자가 표시된다고 해 보겠습니다. 이때 배경 페이지는 오버레이에 가려지고 사용자는 배경의 어떤 요소도 클릭할 수 없습니다. 이것이 Dialog의 핵심 특징입니다. 즉, 사용자가 현재 작업을 먼저 처리하도록 강제합니다.
사용 사례:
- 중요한 안내(삭제 확인, 작업 경고)
- 양식 입력(로그인 창, 회원가입 양식)
- 사용자의 즉각적인 응답이 필요한 작업
접근성 핵심: aria-modal="true"를 반드시 설정하고 포커스 트랩을 구현해야 합니다.
Sheet(측면 서랍)
Sheet는 화면 가장자리에서 슬라이드되어 나오는 서랍형 패널입니다. 본질적으로 Dialog와 마찬가지로 배경 상호작용을 차단하고 포커스 트랩이 필요한 모달 오버레이입니다. 유일한 차이는 화면에 나타나는 위치입니다. Sheet는 측면에서 나오고 Dialog는 가운데에 표시됩니다.
사용 사례:
- 탐색 메뉴(모바일 사이드바)
- 설정 패널(환경설정, 테마 전환)
- 상세 정보 표시(상품 상세, 글 미리보기)
저도 처음에는 Sheet라는 용어가 꽤 낯설었습니다. 알고 보니 Drawer의 다른 이름이었습니다. 어떤 UI 라이브러리는 Drawer라고 부르고, 어떤 라이브러리는 Sheet라고 부르며, Radix UI와 shadcn/ui는 Sheet라는 이름을 사용합니다.
접근성 핵심: Dialog와 마찬가지로 aria-modal="true", 포커스 트랩, Esc 닫기를 지원해야 합니다.
Popover(팝오버)
Popover는 배경 상호작용을 차단하지 않는 비모달 오버레이입니다.
이 차이는 매우 중요합니다. Popover가 열려 있어도 사용자는 여전히 배경 요소를 클릭할 수 있고, 포커스가 Popover 안에 강제로 갇히지 않습니다.
예를 들어 더보기 버튼을 클릭했을 때 편집, 복사, 삭제 항목이 담긴 작은 패널이 열린다고 해 보겠습니다. 이것이 Popover입니다. 배경에 있는 다른 버튼을 클릭하면 Popover가 자동으로 닫힙니다.
사용 사례:
- 드롭다운 메뉴(작업 메뉴, 옵션 목록)
- 도구 설명(리치 텍스트 안내, 사용 방법)
- 빠른 작업(편집, 복사, 삭제)
접근성 핵심: aria-modal="false"로 설정하거나 생략하며, 포커스 트랩을 강제하지 않고 외부를 클릭하면 닫히도록 합니다.
표로 한눈에 보는 차이
이 표는 저도 작성하면서 여러 번 대조해 봤습니다. 그만큼 이전에는 몇몇 개념이 확실하지 않았습니다.
| 특성 | Dialog | Sheet | Popover |
|---|---|---|---|
| 배경 차단 | ✅ 반드시 차단 | ✅ 반드시 차단 | ❌ 차단하지 않음 |
| 포커스 트랩 | 필수 | 필수 | 선택 사항(강제하지 않는 것을 권장) |
| Esc로 닫기 | 필수 지원 | 필수 지원 | 지원 권장 |
| 외부 클릭으로 닫기 | 선택 사항 | 선택 사항 | 기본 동작 |
| ARIA 역할 | dialog | dialog | popover |
aria-modal | "true" | "true" | "false" 또는 생략 |
| 화면 위치 | 가운데 | 측면에서 슬라이드 | 트리거 요소를 기준으로 배치 |
한 문장으로 요약하면 Dialog와 Sheet는 모달 오버레이이고, Popover는 비모달 오버레이입니다. 모달 오버레이에는 포커스 트랩이 반드시 필요하지만, 비모달 오버레이에는 이를 강제하지 않아도 됩니다.
WCAG 접근성 표준 자세히 알아보기
솔직히 처음 WCAG 표준을 접하면 꽤 지루하게 느껴집니다. 영어 용어가 가득하고 법률 문서처럼 읽히기 때문입니다. 하지만 실제 프로젝트에서 문제가 생기고 나면 이 표준이 정말 유용하다는 사실을 알게 됩니다. 단순히 검사를 통과하기 위한 규칙이 아니라 사용자가 기능을 제대로 조작할 수 있게 하는 기준입니다.
필수 ARIA 속성
오버레이 컴포넌트에는 반드시 설정해야 하는 ARIA 속성이 세 가지 있습니다.
1. role="dialog"
이 속성은 보조 기술(스크린 리더)에 현재 요소가 대화상자임을 알려 줍니다.
<div role="dialog">
<!-- 弹层内容 -->
</div>
2. aria-labelledby
이 속성은 오버레이의 제목 요소를 연결합니다. 오버레이가 열리면 스크린 리더가 제목을 먼저 읽습니다.
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">确认删除</h2>
<p>此操作不可撤销。</p>
</div>
3. aria-modal="true"(모달 오버레이에만 해당)
이 속성은 배경 콘텐츠에 접근할 수 없다는 사실을 스크린 리더에 알려 줍니다.
<div role="dialog" aria-modal="true">
<!-- 模态弹层内容 -->
</div>
저는 예전에 aria-labelledby를 자주 빠뜨렸습니다. 나중에 스크린 리더로 테스트해 보니 이 속성이 없으면 사용자가 오버레이를 열었을 때 아무런 설명도 듣지 못해 무엇이 나타났는지 알 수 없었습니다.
키보드 탐색 요구 사항
WCAG는 오버레이의 키보드 탐색에 관해 명확한 요구 사항을 제시합니다.
Tab 키: 오버레이 안에서 포커스 순환
사용자가 Tab 키를 누르면 포커스는 오버레이 안의 상호작용 가능한 요소 사이를 순환해야 하며 배경 페이지로 나가서는 안 됩니다.
Shift+Tab: 포커스를 역방향으로 순환
사용자가 Shift+Tab을 누르면 포커스가 반대 방향으로 순환해야 합니다.
Esc 키: 오버레이 닫기
사용자가 Esc 키를 누르면 오버레이가 닫혀야 합니다. 이는 필수입니다. Esc로 오버레이를 닫는 데 익숙한 사용자가 이 기능을 사용할 수 없다면 오버레이 안에 갇히게 됩니다.
Enter/Space: 버튼 실행
두 키는 버튼이나 링크를 활성화할 때 사용합니다.
저 역시 Tab 순환 때문에 문제를 겪었습니다. 오버레이가 열린 뒤 포커스가 내부로 제한되지 않아 Tab 키를 누르면 배경 페이지로 빠져나갔습니다. 바로 글 앞부분에서 언급한 고객 불만의 원인이었습니다.
포커스 관리 규칙
포커스 관리는 오버레이 접근성에서 가장 놓치기 쉬운 부분입니다. WCAG의 요구 사항은 간단합니다.
오버레이를 열 때:
포커스는 오버레이 안의 첫 번째 상호작용 가능 요소로 이동해야 합니다. 보통 닫기 버튼이나 첫 번째 입력 필드입니다.
오버레이를 닫을 때:
포커스는 오버레이를 연 트리거 요소로 돌아가야 합니다.
솔직히 예전에는 닫은 뒤 포커스를 복원해야 한다는 점을 전혀 몰랐습니다. 키보드로 테스트하고서야 오버레이가 닫힌 뒤 포커스가 어디로 갔는지 알 수 없어 사용자가 다시 찾아야 한다는 사실을 발견했습니다. 사용 경험이 정말 좋지 않았습니다.
특수한 경우:
오버레이에 작업 안내처럼 중요한 내용이 있다면 먼저 컨테이너 요소에 포커스를 두어 스크린 리더가 안내를 읽은 뒤 사용자가 조작할 수 있게 해야 합니다.
컨테이너에 tabindex="0"을 추가하면 됩니다.
<div role="dialog" aria-modal="true" tabindex="0">
<h2>操作说明</h2>
<p>请仔细阅读以下内容再操作...</p>
<button>确认</button>
</div>
이렇게 하면 오버레이가 열릴 때 컨테이너에 먼저 포커스가 놓이고, 스크린 리더가 전체 내용을 읽은 다음 사용자가 Tab 키로 버튼으로 이동할 수 있습니다.
포커스 트랩의 구현 원리
포커스 트랩은 복잡해 보이지만 원리는 사실 간단합니다. Tab 키가 오버레이 안에서만 순환하게 만드는 것입니다.
포커스 트랩이란?
포커스 트랩의 정의는 사용자의 Tab 키 탐색을 특정 영역 안에서만 순환하도록 제한하는 것입니다.
예를 들어 오버레이가 열린 뒤 사용자가 Tab 키를 누르면 포커스가 닫기 버튼에서 확인 버튼으로 이동하고, 다시 Tab 키를 누르면 닫기 버튼으로 돌아옵니다. 이것이 포커스 트랩입니다.
필요한 이유는 사용자가 배경 콘텐츠를 실수로 조작하지 못하게 하기 위해서입니다. 포커스가 배경 페이지로 빠져나가면 사용자가 배경의 버튼을 잘못 눌러 의도하지 않은 작업을 실행할 수 있습니다.
JavaScript 구현 방식
포커스 트랩의 핵심 로직은 간단합니다. 오버레이 안에서 상호작용 가능한 모든 요소를 찾고 Tab 키 입력을 감지한 뒤 첫 번째 요소와 마지막 요소 사이를 순환하게 합니다.
function trapFocus(modal) {
// 找到所有可交互元素
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 监听键盘事件
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
// Shift+Tab:在第一个元素时跳到最后
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
// Tab:在最后一个元素时跳到第一个
else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
// Esc 关闭弹层
if (e.key === 'Escape') {
closeModal();
}
});
}
이 코드는 저도 여러 번 수정한 끝에 제대로 동작시켰습니다. 주요 함정은 다음과 같습니다.
focusableElements선택자는 빠짐없이 작성해야 합니다. 특정 유형을 누락하면 포커스가 밖으로 빠져나갑니다.e.preventDefault()를 반드시 호출해야 합니다. 그렇지 않으면 브라우저의 기본 동작으로 포커스가 외부로 이동합니다.
focus-trap 라이브러리 소개
포커스 트랩을 직접 구현하고 싶지 않다면 focus-trap-react 같은 기존 라이브러리를 사용할 수 있습니다.
import FocusTrap from 'focus-trap-react';
<FocusTrap>
<div className="modal">
<button>关闭</button>
<button>确认</button>
</div>
</FocusTrap>
이 라이브러리는 포커스 순환, Esc 닫기, 중첩 오버레이 같은 상황을 자동으로 처리합니다.
솔직히 저는 이제 이 라이브러리를 거의 사용하지 않습니다. shadcn/ui 자체에 이미 포커스 관리가 통합되어 있기 때문입니다. shadcn/ui의 기반인 Radix UI가 모든 포커스 트랩 로직을 자동으로 처리하므로 별도의 라이브러리를 추가할 필요가 없습니다.
shadcn/ui 실전: Dialog 구현
shadcn/ui를 사용한 뒤로는 오버레이 컴포넌트를 직접 만들지 않습니다. 단순히 귀찮아서가 아닙니다. 직접 작성한 오버레이에는 접근성 문제가 생기기 쉽지만, Radix UI를 기반으로 하는 shadcn/ui는 모든 접근성 세부 사항을 자동으로 처리하기 때문입니다.
설치와 기본 사용법
npx shadcn@latest add dialog
설치가 끝나면 components/ui/dialog.tsx 파일이 자동으로 생성됩니다.
전체 코드 예제
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">删除订单</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
<DialogDescription>
此操作不可撤销,确定要删除这条订单吗?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline">取消</Button>
<Button variant="destructive">删除</Button>
</div>
</DialogContent>
</Dialog>
)
}
이 코드는 단순해 보이지만 내부적으로 Radix UI가 많은 세부 사항을 자동으로 처리합니다.
- 오버레이가 열리면 첫 번째 버튼인
취소로 포커스 이동 - 오버레이가 닫히면
주문 삭제버튼으로 포커스 복원 - 오버레이 안에서 Tab 키 순환
- Esc 키로 오버레이 닫기
aria-labelledby와DialogTitle자동 연결aria-describedby와DialogDescription자동 연결
핵심 접근성 기능
1. 자동 포커스 관리
Radix UI의 Dialog가 열리면 포커스가 오버레이 안의 첫 번째 상호작용 가능 요소로 자동 이동합니다. 닫힐 때는 트리거 요소로 자동 복원됩니다.
2. ARIA 속성 자동 연결
DialogTitle은 aria-labelledby에 자동으로 연결되고, DialogDescription은 aria-describedby에 자동으로 연결됩니다.
<!-- Radix UI 生成的 HTML -->
<div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
<h2 id="radix-:r1:">确认删除</h2>
<p id="radix-:r2:">此操作不可撤销...</p>
</div>
이런 세부 사항은 직접 구현하면 놓치기 쉽지만 shadcn/ui를 사용하면 걱정할 필요가 없습니다.
3. Esc 키로 자동 닫기
Esc 키를 누르면 오버레이가 자동으로 닫히고 포커스가 트리거 요소로 돌아갑니다.
4. 오버레이 배경 클릭으로 닫기
오버레이 바깥의 회색 배경을 클릭해도 닫힙니다. 이 동작은 DialogContent의 onInteractOutside 속성에서 차단할 수 있습니다.
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<!-- 点击遮罩层不会关闭弹层 -->
</DialogContent>
shadcn/ui 실전: Sheet 구현
Sheet와 Dialog의 접근성 기능은 완전히 같습니다. 유일한 차이는 화면에서 나타나는 위치로, Sheet는 측면에서 슬라이드되어 나옵니다.
설치와 기본 사용법
npx shadcn@latest add sheet
전체 코드 예제
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
export function NavigationSheet() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">打开菜单</Button>
</SheetTrigger>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>导航菜单</SheetTitle>
<SheetDescription>
选择你想访问的页面
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-4">
<a href="/" className="hover:underline">首页</a>
<a href="/about" className="hover:underline">关于</a>
<a href="/contact" className="hover:underline">联系</a>
</nav>
</SheetContent>
</Sheet>
)
}
Dialog와의 차이
Sheet와 Dialog의 코드는 컴포넌트 이름만 다를 뿐 거의 같습니다. 주요 차이는 다음과 같습니다.
1. 측면 슬라이드 애니메이션
Sheet는 기본적으로 오른쪽에서 나타나며 side 속성으로 방향을 제어할 수 있습니다.
<SheetContent side="left"> <!-- 左侧滑出 -->
<SheetContent side="right"> <!-- 右侧滑出(默认) -->
<SheetContent side="top"> <!-- 顶部滑出 -->
<SheetContent side="bottom"> <!-- 底部滑出 -->
2. 동일한 접근성 기능
Sheet의 접근성 기능은 Dialog와 완전히 같습니다.
role="dialog"aria-modal="true"- 포커스 트랩, Esc 닫기, 포커스 복원
저는 Sheet를 주로 모바일 탐색 메뉴에 사용합니다. 측면에서 슬라이드되어 나오는 시각적 효과가 모바일의 상호작용 방식에 더 잘 맞습니다.
shadcn/ui 실전: Popover 구현
Popover는 비모달 오버레이입니다. Dialog와 Sheet의 핵심적인 차이는 배경 상호작용을 차단하지 않는다는 점입니다.
설치와 기본 사용법
npx shadcn@latest add popover
전체 코드 예제
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
export function ActionPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">更多操作</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>快捷操作</PopoverTitle>
<PopoverDescription>
选择以下操作
</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-2 mt-2">
<Button size="sm">编辑</Button>
<Button size="sm">复制</Button>
<Button size="sm" variant="destructive">删除</Button>
</div>
</PopoverContent>
</Popover>
)
}
핵심 차이
Popover의 코드는 Dialog 및 Sheet와 비슷하지만 내부 동작은 완전히 다릅니다.
1. 비모달
Popover가 열려 있어도 사용자는 배경 요소를 클릭할 수 있습니다. 포커스가 Popover 내부에 강제로 제한되지 않습니다.
2. 포커스를 강제하지 않음
사용자가 Tab 키를 누르면 포커스가 Popover에서 배경 요소로 이동할 수 있습니다. 이 점은 Dialog와 완전히 다릅니다.
3. 외부 클릭으로 닫기
Popover 바깥의 요소를 클릭하면 자동으로 닫힙니다. 이는 기본 동작이며 onInteractOutside 속성에서 차단할 수 있습니다.
<PopoverContent onInteractOutside={(e) => e.preventDefault()}>
<!-- 点击外部不会关闭 -->
</PopoverContent>
4. 유연한 위치 지정
Popover는 align 속성으로 수평 정렬을 제어합니다.
<PopoverContent align="start"> <!-- 左对齐 -->
<PopoverContent align="center"> <!-- 居中对齐(默认) -->
<PopoverContent align="end"> <!-- 右对齐 -->
저는 Popover를 주로 작업 메뉴에 사용합니다. 버튼을 클릭하면 몇 가지 빠른 작업 옵션이 나타나는 방식입니다. 이런 상황에서는 배경 상호작용을 차단할 필요가 없으므로 Popover가 잘 맞습니다.
고급 팁과 흔한 문제
오버레이 컴포넌트를 만들며 여러 문제를 겪었습니다. 그중 가장 흔한 몇 가지를 살펴보겠습니다.
포커스 복원의 함정: 트리거 요소가 삭제된 경우
상황: 오버레이가 열린 뒤 트리거 요소인 버튼이 삭제되어 오버레이를 닫을 때 포커스를 되돌릴 곳이 없습니다.
해결 방법:
- 트리거 요소를 삭제하지 말고 숨기기만 합니다.
- 또는 포커스를 복원할 대상 요소를 기록합니다.
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
// 打开弹层时记录触发元素
const handleOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
setTriggerElement(e.currentTarget);
setOpen(true);
};
// 关闭弹层时恢复焦点
const handleClose = () => {
setOpen(false);
triggerElement?.focus();
};
저도 이 문제를 겪었습니다. 사용자가 항목 하나를 삭제하고 오버레이를 닫으면 포커스가 어디로 갔는지 알 수 없었습니다. 포커스를 목록의 이전 항목으로 옮기고서야 해결할 수 있었습니다.
스크린 리더의 함정: 오버레이 내용을 읽지 않는 경우
상황: 오버레이가 열린 뒤에도 스크린 리더가 내용을 읽지 않아 사용자가 무엇이 표시되었는지 알 수 없습니다.
원인:
aria-labelledby속성이 없습니다.- 포커스가 오버레이 안으로 이동하지 않았습니다.
해결 방법:
DialogTitle과 DialogDescription을 모두 설정해야 합니다. shadcn/ui는 ARIA 속성을 자동으로 연결합니다.
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除</DialogTitle> <!-- 必须有 -->
<DialogDescription>此操作不可撤销</DialogDescription> <!-- 必须有 -->
</DialogHeader>
</DialogContent>
저는 예전에 DialogDescription을 자주 빠뜨렸습니다. 이후 NVDA 스크린 리더로 테스트하면서 설명이 없으면 사용자가 오버레이 제목만 알 뿐 구체적인 내용을 알 수 없다는 사실을 발견했습니다.
중첩 오버레이의 함정: 복잡해지는 포커스 관리
상황: 오버레이 A가 오버레이 B를 열고, B를 닫은 뒤 포커스가 어디로 갔는지 알 수 없습니다.
해결 방법:
Radix UI의 Dialog와 Sheet는 중첩을 지원합니다. 내부 오버레이를 닫으면 포커스가 내부 오버레이의 트리거 요소, 즉 외부 오버레이 안에 있는 버튼으로 돌아갑니다.
<Dialog>
<DialogTrigger>打开弹层 A</DialogTrigger>
<DialogContent>
<DialogTitle>弹层 A</DialogTitle>
<!-- 弹层 A 内打开弹层 B -->
<Dialog>
<DialogTrigger>打开弹层 B</DialogTrigger>
<DialogContent>
<DialogTitle>弹层 B</DialogTitle>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
저는 가능한 한 중첩 오버레이를 피합니다. 꼭 필요하다면 Radix UI의 중첩 지원을 사용해 포커스를 자동으로 처리하게 합니다.
애니메이션 지연의 함정: 포커스가 오버레이 안에 없는 경우
상황: 오버레이에 페이드인 같은 애니메이션이 있고, 애니메이션이 진행되는 동안 포커스가 오버레이 안에 놓이지 않습니다.
원인:
애니메이션이 시작될 때는 오버레이가 아직 완전히 표시되지 않아 포커스 설정이 실패합니다.
해결 방법:
Radix UI는 이 문제를 자동으로 처리하며 애니메이션이 끝난 뒤 포커스를 설정합니다.
직접 구현한다면 애니메이션이 끝날 때까지 기다려야 합니다.
modal.addEventListener('animationend', () => {
const firstFocusable = modal.querySelector('button, [href], input');
firstFocusable?.focus();
});
저도 이 문제를 겪었습니다. 직접 만든 오버레이는 완전히 나타나기 전에 포커스 설정을 시도했기 때문에 열린 뒤에도 포커스가 배경 버튼에 남아 있었습니다. animationend 이벤트 리스너를 추가하고서야 해결할 수 있었습니다.
정리
지금까지 설명한 내용을 세 가지 핵심으로 요약할 수 있습니다.
1. 세 컴포넌트의 핵심 차이
Dialog와 Sheet는 모달 오버레이이므로 배경 상호작용을 차단하고 포커스 트랩을 구현해야 합니다.
Popover는 비모달 오버레이이므로 배경 상호작용을 차단하지 않으며 포커스를 강제하지 않습니다.
2. WCAG 접근성의 세 가지 주요 요구 사항
ARIA 속성(role="dialog", aria-labelledby, aria-modal="true")
키보드 탐색(Tab 순환, Shift+Tab 역방향 순환, Esc 닫기)
포커스 관리(열 때 포커스 이동, 닫을 때 포커스 복원)
3. shadcn/ui가 모든 세부 사항을 자동으로 처리
Radix UI는 포커스 트랩, ARIA 속성, 키보드 탐색을 자동으로 처리합니다. shadcn/ui를 사용하면 접근성 문제를 대부분 걱정하지 않아도 됩니다.
여러 오버레이 컴포넌트를 만들어 본 뒤 제 원칙은 단순해졌습니다. 프로덕션 환경에서는 shadcn/ui를 우선 사용합니다. 직접 만든 오버레이는 접근성 문제가 끊이지 않지만, Radix UI를 기반으로 한 shadcn/ui는 이런 세부 사항을 이미 처리하고 있습니다.
다만 원리를 이해하는 일은 여전히 중요합니다. Radix UI가 내부에서 무엇을 하는지 알아야 문제가 생겼을 때 빠르게 원인을 찾을 수 있습니다.
참고 자료
- WAI-ARIA dialog role - MDN
- Radix UI Accessibility
- WCAG 2.1 Quick Reference
- Mastering Accessible Modals
- focus-trap-react
FAQ
Dialog, Sheet, Popover 세 컴포넌트의 차이는 무엇인가요?
오버레이 컴포넌트는 어떤 접근성 요구 사항을 구현해야 하나요?
포커스 트랩이란 무엇이며 모달 오버레이에 왜 필요한가요?
shadcn/ui의 Dialog 컴포넌트는 어떤 접근성 세부 사항을 자동으로 처리하나요?
• 오버레이를 열 때 첫 번째 상호작용 가능 요소로 포커스 이동
• 오버레이를 닫을 때 트리거 요소로 포커스 복원
• 오버레이 안에서 Tab 키 순환
• Esc 키로 자동 닫기
• aria-labelledby와 DialogTitle 자동 연결
• aria-describedby와 DialogDescription 자동 연결
오버레이를 닫은 뒤 포커스는 어디로 돌아가야 하나요?
오버레이에 애니메이션이 있을 때 포커스 설정이 실패하면 어떻게 해야 하나요?
스크린 리더가 오버레이 내용을 읽게 하려면 어떻게 해야 하나요?
5분 읽기 · 게시일: 2026년 3월 29일 · 수정일: 2026년 9월 4일
Tailwind & shadcn/ui 실전
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
shadcn/ui와 Radix: 컴포넌트를 커스터마이징하면서 접근성 지키기
shadcn/ui의 기반인 Radix Primitives를 이해하고, asChild 사용법과 포커스 관리, ARIA 속성 상속을 점검해 커스텀 컴포넌트의 키보드 접근성을 유지하는 방법을 설명합니다.
14편 중 9편
다음
Tailwind 성능 최적화: JIT, content 설정 및 프로덕션 용량 관리
Tailwind CSS JIT 모드의 작동 원리, content 설정 모범 사례, 프로덕션 용량을 줄이는 4단계 최적화 전략과 실전 사례, Tailwind v4의 새로운 기능을 자세히 알아봅니다.
14편 중 11편



댓글
GitHub로 로그인하여 댓글을 남기세요