Membuat modal dialog aksesibel dengan Claude Code: React dan dialog
Panduan membuat modal dengan Claude Code: dialog element, React, focus handling, pitfall, test, dan aksesibilitas.
Modal dialog adalah layer sementara di atas halaman yang meminta keputusan atau input singkat dari pengguna. Tantangannya bukan membuat kotak di tengah layar. Modal yang benar membuat background tidak bisa dioperasikan, memindahkan focus ke dalam dialog, menutup dengan cara yang jelas, lalu mengembalikan focus ke tombol yang membukanya.
Jika kamu meminta Claude Code “buat modal yang bagus”, hasilnya bisa terlihat rapi tetapi rusak saat dipakai: Escape tidak menutup, Tab keluar ke background, screen reader tidak membaca title, atau tombol footer hilang di layar kecil. Artikel ini mengubah permintaan itu menjadi brief implementasi, contoh yang bisa dijalankan, use case nyata, pitfall, dan test.
Gunakan referensi resmi saat review: MDN untuk elemen <dialog>, WAI-ARIA APG Modal Dialog Pattern, WCAG Focus Order, dan Focus Visible. Bacaan terkait: aksesibilitas dengan Claude Code, Radix UI, command palette, dan toast notification.
Putuskan sebelum membuat
Modal cocok untuk tugas pendek yang memang perlu menghentikan halaman: konfirmasi hapus, membatalkan paket, mengganti role, mengundang anggota, login sebelum checkout, atau command palette.
Modal tidak cocok untuk form panjang, teks legal penuh, alur multi halaman, iklan agresif, atau informasi yang aman dibaca nanti. Sebelum meminta kode, tentukan apakah halaman memang perlu diblokir, focus pertama harus ke mana, aksi apa saja yang menutup dialog, dan apakah footer tetap bisa dipakai pada lebar 320px.
Definisi sederhana membantu Claude Code. Focus adalah posisi keyboard saat ini. Focus trap menjaga Tab tetap di dalam dialog. inert berarti background tidak interaktif. ARIA adalah atribut yang menjelaskan makna UI ke assistive technology.
flowchart TD
A["Pengguna menekan trigger"] --> B["Buka dengan dialog.showModal()"]
B --> C["Pindahkan focus ke title atau aksi pertama"]
C --> D["Cek Tab, Shift+Tab, dan Escape"]
D --> E["Pisahkan confirm, cancel, dan backdrop"]
E --> F["Kembalikan focus ke trigger"]
Brief untuk Claude Code
Mulai dari perilaku, bukan styling.
Tambahkan modal dialog ke screen React + TypeScript yang sudah ada.
Requirement:
- Baca button, form, CSS, dan test yang ada sebelum edit.
- Prioritaskan HTML dialog element. Jelaskan jika tidak cocok.
- Saat dibuka, pindahkan focus ke title atau aksi pertama yang bermakna.
- Tangani Escape, cancel, confirm, dan backdrop click secara terpisah.
- Saat ditutup, kembalikan focus ke button yang membuka dialog.
- Gunakan aria-labelledby dan aria-describedby jika ada deskripsi singkat.
- Jangan hapus outline. Gunakan :focus-visible untuk focus yang terlihat.
- Pada lebar 320px, content dan footer button tetap bisa digunakan.
- Tulis failure case dan langkah manual verification di handoff.
File yang boleh diedit:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
HTML yang bisa langsung dijalankan
Simpan sebagai modal-demo.html, lalu buka di browser.
<!doctype html>
<html lang="id">
<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>Project settings</h1>
<p>Gunakan modal hanya untuk aksi yang memang perlu menghentikan halaman.</p>
<button id="open-dialog" class="danger" type="button">
Hapus project
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">Hapus project ini?</h2>
<p>Aksi ini tidak bisa dibatalkan. Export data dulu jika perlu.</p>
<div class="button-row">
<button id="cancel-dialog" type="button">Batal</button>
<button id="confirm-delete" class="danger" type="button">
Hapus
</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>
Untuk modal, gunakan showModal(). Menambahkan atribut open secara manual bisa membuat background tetap interaktif.
Komponen React reusable
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="Tutup dialog"
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%;
}
}
Tiga use case nyata
| Use case | Kenapa cocok modal | Tambahan untuk Claude Code |
|---|---|---|
| Hapus, batalkan paket, ubah role | Sulit dibatalkan | Copy berisiko, guard double-submit, audit log |
| Undangan, billing, setting pendek | Selesai tanpa pindah konteks | Validasi, pending state, focus setelah sukses |
| Command palette atau search cepat | Aksi cepat tanpa navigasi | Arrow keys, aria-activedescendant, empty state |
Untuk aksi berbahaya, backdrop click tidak selalu boleh menutup. Untuk form pendek, jangan tutup saat error; tampilkan error di dalam modal dan buat bisa dibaca screen reader.
Konfirmasi berbasis 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 ?? "Batal"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "Konfirmasi"}
</button>
</>
}
>
<p>Periksa detail sebelum melanjutkan.</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
Pitfall umum
Pertama, tombol close hanya bekerja dengan mouse. Gunakan button, beri accessible name, dan test Enter serta Space.
Kedua, title dihapus demi tampilan. Dialog tetap perlu nama aksesibel lewat aria-labelledby.
Ketiga, outline: none tanpa pengganti. Gunakan :focus-visible.
Keempat, menumpuk modal. Ini membuat focus return dan arti Escape membingungkan. Lebih baik satu konfirmasi jelas atau undo.
Kelima, lupa mobile. Gunakan max-height, overflow: auto, dan cek 320px.
Test Playwright minimal
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: "Hapus project" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "Hapus project ini?",
});
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();
});
Manual QA tetap perlu: pakai keyboard saja, lalu NVDA atau VoiceOver, lalu viewport mobile sempit.
CTA dan monetisasi
Modal sering dekat dengan revenue: checkout, lead form, konsultasi, email capture. Karena itu modal harus membantu, bukan mengganggu.
Untuk tim yang ingin merapikan Claude Code, CLAUDE.md, review UI aksesibel, dan workflow React, gunakan training dan konsultasi Claude Code. Untuk individu, mulai dari produk dan cheatsheet gratis.
Hasil praktik
Saat Masa mencoba pola ini pada screen settings React kecil, perubahan paling efektif bukan animasi. Acceptance criteria seperti mengembalikan focus ke trigger, tidak menutup aksi berbahaya lewat backdrop, dan memastikan footer usable pada 320px membuat output Claude Code jauh lebih mudah direview.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission safety ladder Claude Code: perluas akses tanpa kehilangan kontrol
Naik dari read-only ke edit terbatas, command bukti, dan cek deploy dengan kontrol yang jelas.
Claude Code Small PR Proof Pack: perubahan kecil yang mudah direview
Paket bukti untuk PR Claude Code: diff, check, URL publik, jalur CTA, dan rollback.
Review gate Claude Code sebelum commit: diff, test, URL publik, dan CTA
Cara memakai Claude Code sebelum commit: diff scope, build, URL publik, link Gumroad, CTA konsultasi, missing test, dan file tidak terkait.