Créer des modales accessibles avec Claude Code: React, dialog, focus
Guide pratique pour créer des modales avec Claude Code: dialog, React, focus, pièges, tests et accessibilité.
Une boîte de dialogue modale est une couche temporaire qui demande une décision ou une courte saisie avant de revenir à la page. Le vrai sujet n’est pas la boîte centrée. Une bonne modale rend l’arrière-plan inactif, place le focus dans la fenêtre, permet une fermeture prévisible et rend le focus au bouton qui l’a ouverte.
Si vous demandez seulement à Claude Code de “faire une belle modale”, le résultat peut sembler propre mais échouer en usage réel: Escape ne ferme pas, Tab passe derrière la modale, le lecteur d’écran n’annonce pas le titre ou les boutons sortent de l’écran mobile. Cette page donne un brief clair, des exemples copiables, des cas d’usage, des pièges et des tests.
Les références officielles sont indispensables: MDN sur l’élément <dialog>, WAI-ARIA APG Modal Dialog Pattern, WCAG Focus Order et Focus Visible. Côté ClaudeCodeLab, consultez aussi l’accessibilité, Radix UI, la palette de commandes et les notifications toast.
Décider avant de coder
Une modale convient aux tâches courtes qui doivent suspendre la page: confirmation de suppression, annulation d’abonnement, changement de rôle, invitation, connexion avant achat ou palette de commandes.
Elle convient mal aux longs formulaires, textes légaux complets, parcours multi-pages, publicités agressives ou messages que l’utilisateur peut lire plus tard. Avant de demander du code, décidez si l’action mérite de bloquer la page, où va le premier focus, quelles actions ferment la fenêtre et si tout reste utilisable à 320px.
Quelques définitions simples aident Claude Code. Le focus est la position actuelle du clavier. Un focus trap garde Tab dans la modale. inert signifie que l’arrière-plan n’est plus interactif. ARIA donne du sens à l’interface pour les technologies d’assistance.
flowchart TD
A["Activation du bouton"] --> B["Ouverture avec dialog.showModal()"]
B --> C["Focus vers titre ou première action"]
C --> D["Vérifier Tab, Shift+Tab, Escape"]
D --> E["Séparer confirmer, annuler et clic extérieur"]
E --> F["Rendre le focus au déclencheur"]
Brief pour Claude Code
Commencez par le comportement. Copiez ce brief et remplacez les chemins.
Ajoute une boîte de dialogue modale à l'écran React + TypeScript existant.
Exigences:
- Lire les boutons, formulaires, CSS et tests existants avant d'éditer.
- Privilégier l'élément HTML dialog. Expliquer si ce n'est pas adapté.
- À l'ouverture, déplacer le focus vers le titre ou la première action utile.
- Gérer Escape, annulation, confirmation et clic extérieur séparément.
- À la fermeture, rendre le focus au bouton qui a ouvert la modale.
- Utiliser aria-labelledby et aria-describedby si une description existe.
- Ne pas supprimer outline. Utiliser :focus-visible pour un focus visible.
- À 320px, le contenu et les boutons doivent rester utilisables.
- Laisser les cas d'échec et la vérification manuelle dans le handoff.
Fichiers autorisés:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
HTML exécutable
Enregistrez ce fichier sous modal-demo.html puis ouvrez-le dans un navigateur.
<!doctype html>
<html lang="fr">
<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>Paramètres du projet</h1>
<p>Utilisez une modale seulement pour une action qui doit interrompre.</p>
<button id="open-dialog" class="danger" type="button">
Supprimer le projet
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">Supprimer ce projet ?</h2>
<p>Cette action est irréversible. Exportez les données si nécessaire.</p>
<div class="button-row">
<button id="cancel-dialog" type="button">Annuler</button>
<button id="confirm-delete" class="danger" type="button">
Supprimer
</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>
Pour une modale, utilisez showModal() plutôt que l’attribut open ajouté manuellement. open seul peut laisser l’arrière-plan interactif.
Composant React réutilisable
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="Fermer la boîte de dialogue"
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%;
}
}
Trois cas concrets
| Cas | Pourquoi une modale | À demander à Claude Code |
|---|---|---|
| Suppression, résiliation, changement de rôle | Action difficile à annuler | Texte de danger, anti double-clic, journal |
| Invitation, facturation, réglage court | Finir sans quitter le contexte | Validation, état d’envoi, focus après succès |
| Palette de commandes ou recherche | Action rapide sans navigation | Flèches, aria-activedescendant, état vide |
Pour une action destructive, décidez si le clic extérieur doit fermer. Pour un formulaire court, gardez la modale ouverte en cas d’erreur et rendez l’erreur lisible.
Confirmation avec 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 ?? "Annuler"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "Confirmer"}
</button>
</>
}
>
<p>Vérifiez les détails avant de continuer.</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
Pièges fréquents
Le premier piège est un bouton de fermeture utilisable seulement à la souris. Utilisez un vrai button et testez Enter et Space.
Le deuxième est la suppression du titre. Même invisible, la modale doit avoir un nom accessible via aria-labelledby.
Le troisième est outline: none sans remplacement. Utilisez :focus-visible.
Le quatrième est l’empilement de modales. Préférez une confirmation claire ou une action d’annulation.
Le cinquième est l’oubli du mobile. Ajoutez max-height, overflow: auto et testez à 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: "Supprimer le projet" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "Supprimer ce projet ?",
});
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();
});
La vérification manuelle reste nécessaire: clavier seul, puis NVDA ou VoiceOver, puis largeur mobile.
CTA et monétisation
Les modales sont proches du revenu: paiement, demande de consultation, inscription e-mail, demande de formation. Elles doivent donc être sobres et utiles, jamais intrusives.
Pour structurer Claude Code, CLAUDE.md, les revues d’accessibilité et les écrans React existants, consultez la formation et consultation Claude Code. Pour démarrer seul, utilisez les produits et la fiche gratuite.
Résultat
Dans un petit écran de réglages React, Masa a constaté que les critères “rendre le focus au déclencheur”, “ne pas fermer une action dangereuse par clic extérieur” et “garder les boutons utilisables à 320px” réduisaient davantage les retours de revue que les consignes de style.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Échelle de sécurité des permissions Claude Code
Passer du read-only aux éditions limitées, preuves et checks de déploiement sans perdre le contrôle.
Claude Code Small PR Proof Pack : rendre les petits changements reviewables
Un pack de preuve pour PR Claude Code : diff, vérifications, URL publique, CTA et rollback.
Gate de review avant commit avec Claude Code
Review avant commit avec Claude Code : diff, build, URL publique, liens Gumroad, CTA consultation, tests manquants et fichiers hors scope.