Tips & Tricks (Diperbarui: 2/6/2026)

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.

Membuat modal dialog aksesibel dengan Claude Code: React dan dialog

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 caseKenapa cocok modalTambahan untuk Claude Code
Hapus, batalkan paket, ubah roleSulit dibatalkanCopy berisiko, guard double-submit, audit log
Undangan, billing, setting pendekSelesai tanpa pindah konteksValidasi, pending state, focus setelah sukses
Command palette atau search cepatAksi cepat tanpa navigasiArrow 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.

#Claude Code #modal #dialog #React #aksesibilitas
Gratis

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.