Claude Code로 접근 가능한 모달 다이얼로그 구현하기
Claude Code로 dialog 요소, React 컴포넌트, 포커스 처리, 실패 사례, 접근성 테스트까지 구현하는 가이드.
모달 다이얼로그는 현재 화면 위에 잠시 나타나 사용자의 결정이나 짧은 입력을 받는 UI입니다. 핵심은 가운데 뜨는 상자가 아닙니다. 열려 있는 동안 배경 화면을 조작하지 못하게 하고, 키보드 포커스를 안으로 이동시키며, 닫힌 뒤에는 원래 버튼으로 포커스를 돌려줘야 합니다.
Claude Code에 “예쁜 모달을 만들어 줘”라고만 요청하면 겉보기는 괜찮지만 실제로는 깨진 구현이 나올 수 있습니다. Escape로 닫히지 않거나, Tab이 배경으로 빠지거나, 스크린 리더가 제목을 읽지 못하거나, 모바일에서 하단 버튼이 잘리는 식입니다. 이 글은 요구사항, 실행 가능한 예제, 현실적인 유스케이스, 실패 사례, 테스트 방법을 한 번에 정리합니다.
공식 기준은 함께 확인하세요. 브라우저 기본 기능은 MDN <dialog> 요소, 키보드 동작은 WAI-ARIA APG Modal Dialog Pattern, 포커스 품질은 WCAG Focus Order와 Focus Visible이 기준입니다. 관련 글로는 접근성 구현, Radix UI 활용, 커맨드 팔레트, 토스트 알림을 참고하세요.
만들기 전에 결정할 것
모달은 현재 문맥을 유지한 채 짧게 끝나는 작업에 적합합니다. 삭제 확인, 구독 취소, 권한 변경, 팀원 초대, 결제 전 로그인, 커맨드 팔레트가 대표적인 예입니다.
반대로 긴 폼, 약관 전문, 여러 페이지에 걸친 흐름, 광고성 팝업, 나중에 읽어도 되는 공지는 모달에 적합하지 않습니다. Claude Code에 코드를 맡기기 전에 이 작업이 페이지를 멈출 만큼 중요한지, 처음 포커스가 어디로 가야 하는지, 어떤 동작으로 닫을지, 320px 폭에서도 버튼이 보이는지 결정하세요.
용어도 쉽게 정리해 둡니다. 포커스는 “키보드의 현재 위치”입니다. 포커스 트랩은 “Tab 이동을 다이얼로그 안에 가두는 것”입니다. inert는 “배경을 조작 대상에서 제외하는 상태”입니다. ARIA는 “보조 기술에 UI 의미를 알려주는 속성”입니다.
flowchart TD
A["사용자가 버튼을 누름"] --> B["dialog.showModal()로 열기"]
B --> C["제목 또는 첫 동작으로 포커스 이동"]
C --> D["Tab, Shift+Tab, Escape 확인"]
D --> E["확인, 취소, 배경 클릭 분리"]
E --> F["닫힌 뒤 트리거 버튼으로 포커스 반환"]
Claude Code에 줄 구현 브리프
스타일보다 동작 조건을 먼저 전달하세요. 아래 문장을 그대로 붙여 넣고 파일명만 바꾸면 됩니다.
기존 React + TypeScript 화면에 모달 다이얼로그를 추가해 주세요.
요구사항:
- 기존 버튼, 폼, CSS, 테스트를 먼저 읽고 수정한다.
- HTML dialog 요소를 우선 사용하고, 부적합하면 이유를 설명한다.
- 열리면 제목 또는 첫 의미 있는 동작으로 포커스를 이동한다.
- Escape, 취소, 확인, 배경 클릭을 각각 분리해 처리한다.
- 닫히면 모달을 연 버튼으로 포커스를 되돌린다.
- aria-labelledby를 사용하고, 짧은 설명이 있으면 aria-describedby를 쓴다.
- outline을 제거하지 말고 :focus-visible로 보이는 포커스를 제공한다.
- 320px 폭에서도 본문과 하단 버튼이 잘리지 않게 한다.
- 실패 사례와 수동 확인 절차를 핸드오프에 남긴다.
수정 가능 파일:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
바로 실행할 수 있는 HTML 예제
다음 코드를 modal-demo.html로 저장하고 브라우저에서 열면 프레임워크 없이 showModal(), close(), 배경 클릭, 포커스 반환을 확인할 수 있습니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dialog demo</title>
<style>
body {
font-family: system-ui, sans-serif;
line-height: 1.7;
padding: 2rem;
}
button {
font: inherit;
border: 0;
border-radius: 6px;
padding: 0.7rem 1rem;
cursor: pointer;
}
.danger {
background: #dc2626;
color: white;
}
dialog {
width: min(calc(100vw - 2rem), 28rem);
border: 0;
border-radius: 8px;
padding: 0;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
dialog::backdrop {
background: rgb(15 23 42 / 0.58);
}
.modal-body {
padding: 1.25rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
</style>
</head>
<body>
<main>
<h1>프로젝트 설정</h1>
<p>되돌리기 어려운 작업만 모달로 한 번 멈춥니다.</p>
<button id="open-dialog" class="danger" type="button">
프로젝트 삭제
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">이 프로젝트를 삭제할까요?</h2>
<p>이 작업은 되돌릴 수 없습니다. 필요하면 먼저 데이터를 내보내세요.</p>
<div class="button-row">
<button id="cancel-dialog" type="button">취소</button>
<button id="confirm-delete" class="danger" type="button">
삭제
</button>
</div>
</div>
</dialog>
<script>
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#confirm-dialog");
const title = document.querySelector("#dialog-title");
const cancelButton = document.querySelector("#cancel-dialog");
const confirmButton = document.querySelector("#confirm-delete");
openButton.addEventListener("click", () => {
dialog.showModal();
title.focus();
});
cancelButton.addEventListener("click", () => dialog.close("cancel"));
confirmButton.addEventListener("click", () => {
console.log("delete project");
dialog.close("confirm");
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close("backdrop");
}
});
dialog.addEventListener("close", () => {
openButton.focus();
console.log(`closed by: ${dialog.returnValue || "unknown"}`);
});
</script>
</body>
</html>
모달로 열 때는 open 속성을 직접 붙이지 말고 showModal()을 사용합니다. open만 있으면 배경이 계속 조작될 수 있어 모달 의미가 흐려집니다.
React 공통 컴포넌트
실무에서는 삭제 확인, 결제 설정, 초대 폼처럼 반복되는 UI를 하나의 컴포넌트로 통일하는 편이 안전합니다.
import * as React from "react";
import "./modal-dialog.css";
type ModalDialogProps = {
open: boolean;
title: string;
description?: string;
closeOnBackdrop?: boolean;
onClose: () => void;
children: React.ReactNode;
footer: React.ReactNode;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function ModalDialog({
open,
title,
description,
closeOnBackdrop = true,
onClose,
children,
footer,
}: ModalDialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleRef = React.useRef<HTMLHeadingElement>(null);
const openerRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
dialog.showModal();
window.requestAnimationFrame(() => {
const preferred = dialog.querySelector<HTMLElement>("[data-autofocus]");
const firstFocusable = dialog.querySelector<HTMLElement>(
focusableSelector,
);
(preferred ?? firstFocusable ?? titleRef.current)?.focus();
});
}
if (!open && dialog.open) {
dialog.close();
}
}, [open]);
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleClose() {
onClose();
openerRef.current?.focus();
}
function handleClick(event: MouseEvent) {
if (event.target === dialog && closeOnBackdrop) {
onClose();
}
}
dialog.addEventListener("close", handleClose);
dialog.addEventListener("click", handleClick);
return () => {
dialog.removeEventListener("close", handleClose);
dialog.removeEventListener("click", handleClick);
};
}, [closeOnBackdrop, onClose]);
return (
<dialog
ref={dialogRef}
className="app-modal"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<div className="app-modal__body">
<div className="app-modal__header">
<h2 id={titleId} ref={titleRef} tabIndex={-1}>
{title}
</h2>
<button
type="button"
className="app-modal__icon"
aria-label="다이얼로그 닫기"
onClick={onClose}
>
x
</button>
</div>
{description ? (
<p id={descriptionId} className="app-modal__description">
{description}
</p>
) : null}
<div className="app-modal__content">{children}</div>
<div className="app-modal__footer">{footer}</div>
</div>
</dialog>
);
}
.app-modal {
width: min(calc(100vw - 32px), 520px);
max-height: calc(100vh - 32px);
border: 0;
border-radius: 8px;
padding: 0;
color: #0f172a;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
.app-modal::backdrop {
background: rgb(15 23 42 / 0.58);
}
.app-modal__body {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal__header,
.app-modal__footer {
display: flex;
gap: 12px;
}
.app-modal__header {
align-items: flex-start;
justify-content: space-between;
}
.app-modal__footer {
flex-wrap: wrap;
justify-content: flex-end;
}
.app-modal__icon {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
@media (max-width: 480px) {
.app-modal__footer {
flex-direction: column-reverse;
}
.app-modal__footer button {
width: 100%;
}
}
현실적인 유스케이스 3가지
| 유스케이스 | 모달이 맞는 이유 | Claude Code에 추가 요청 |
|---|---|---|
| 삭제, 해지, 권한 변경 | 되돌리기 어려워 한 번 멈춰야 함 | 위험 문구, 중복 제출 방지, 감사 로그 |
| 초대, 청구, 짧은 설정 폼 | 현재 화면 문맥을 유지한 채 끝남 | 검증 오류, 제출 중 상태, 성공 후 포커스 |
| 커맨드 팔레트와 빠른 검색 | 페이지 이동 없이 빠르게 실행 | 방향키, aria-activedescendant, 빈 상태 |
위험한 작업은 배경 클릭으로 닫을지 신중히 정하세요. 짧은 폼은 오류가 있을 때 닫지 말고 모달 안에서 읽히는 오류를 보여줘야 합니다.
Promise 기반 확인 함수
관리 화면에서는 확인 후 계속하는 패턴이 자주 필요합니다.
import * as React from "react";
import { createRoot } from "react-dom/client";
import { ModalDialog } from "./ModalDialog";
type ConfirmDialogOptions = {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
};
export function confirmDialog(
options: ConfirmDialogOptions,
): Promise<boolean> {
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function finish(result: boolean) {
root.unmount();
container.remove();
resolve(result);
}
function ConfirmHost() {
return (
<ModalDialog
open
title={options.title}
description={options.message}
closeOnBackdrop={false}
onClose={() => finish(false)}
footer={
<>
<button type="button" onClick={() => finish(false)}>
{options.cancelLabel ?? "취소"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "확인"}
</button>
</>
}
>
<p>계속하기 전에 내용을 확인하세요.</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
실패 사례와 접근성 확인
첫째, 닫기 컨트롤이 마우스 전용이면 안 됩니다. 실제 button을 쓰고 아이콘 버튼에는 접근 가능한 이름을 주세요.
둘째, 디자인상 필요 없다고 제목을 없애면 안 됩니다. 보이지 않더라도 aria-labelledby로 이름을 연결해야 합니다.
셋째, outline: none만 넣으면 키보드 사용자는 현재 위치를 잃습니다. :focus-visible로 대체 스타일을 제공하세요.
넷째, 모달 위에 또 모달을 쌓으면 Escape 의미와 포커스 반환이 복잡해집니다. 가능하면 하나의 명확한 확인 단계나 Undo를 사용하세요.
다섯째, 모바일 높이 부족을 확인해야 합니다. max-height와 overflow: auto를 넣고 320px 폭에서 마지막 버튼까지 눌러 보세요.
Playwright 최소 테스트
import { expect, test } from "@playwright/test";
test("modal opens, closes, and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "프로젝트 삭제" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "이 프로젝트를 삭제할까요?",
});
await expect(dialog).toBeVisible();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
수동 확인에서는 마우스 없이 Tab, Shift+Tab, Enter, Space, Escape만 사용합니다. 이후 NVDA나 VoiceOver로 제목, 설명, 버튼 이름이 읽히는지 확인하고, 마지막으로 좁은 모바일 폭을 봅니다.
CTA와 수익화
모달은 구매 확인, 상담 신청, 이메일 등록처럼 수익 경로와 가까운 곳에 놓이는 경우가 많습니다. 그래서 더 조심해야 합니다. 광고처럼 독자를 가로막기보다, 필요한 순간에만 짧고 명확하게 사용하세요.
팀에서 Claude Code 도입, CLAUDE.md, 접근성 UI 리뷰, React 화면 개선을 함께 정리하려면 Claude Code 교육과 상담을 활용할 수 있습니다. 개인 개발자는 제품 목록과 무료 치트시트로 프롬프트, 리뷰, 테스트 습관부터 고정해 두면 좋습니다.
실제 확인 결과
Masa가 작은 React 설정 화면에서 이 패턴을 시험했을 때, 가장 큰 효과는 애니메이션이 아니라 완료 조건이었습니다. “닫히면 트리거로 포커스 반환”, “위험 작업은 배경 클릭으로 닫지 않기”, “320px 폭에서도 하단 버튼 사용 가능”을 Claude Code에 먼저 전달하자 리뷰 수정이 크게 줄었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.