Accessible modals are essential for inclusive web apps. Here is how to build one properly with focus trapping and ARIA.
Step 1: The HTML Dialog Element
The native <dialog> element provides accessibility features built-in.
"use client";
import { useRef, useEffect } from "react";
export function NativeDialog({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal(); // Blocks interaction with background
} else {
dialog.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="rounded-2xl border-0 bg-white p-0 shadow-2xl backdrop:bg-black/50 dark:bg-gray-900"
>
{children}
</dialog>
);
}
Step 2: Custom Accessible Modal
"use client";
import { useEffect, useRef, useCallback } from "react";
import { X } from "lucide-react";
import { createPortal } from "react-dom";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
size?: "sm" | "md" | "lg";
}
export function Modal({
open,
onClose,
title,
description,
children,
size = "md",
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const sizes = {
sm: "max-w-sm",
md: "max-w-lg",
lg: "max-w-2xl",
};
// Save and restore focus
useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the modal after a tick so the content is rendered
requestAnimationFrame(() => {
modalRef.current?.focus();
});
} else {
previousFocusRef.current?.focus();
}
}, [open]);
// Lock body scroll
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
// Handle keyboard
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
// Focus trapping
if (e.key === "Tab") {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
}
}
},
[onClose]
);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onKeyDown={handleKeyDown}
>
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? "modal-description" : undefined}
tabIndex={-1}
className={`relative w-full ${sizes[size]} rounded-2xl bg-white shadow-2xl focus:outline-none dark:bg-gray-900`}
>
{/* Header */}
<div className="flex items-start justify-between border-b p-6 dark:border-gray-700">
<div>
<h2
id="modal-title"
className="text-lg font-semibold"
>
{title}
</h2>
{description && (
<p
id="modal-description"
className="mt-1 text-sm text-gray-500"
>
{description}
</p>
)}
</div>
<button
onClick={onClose}
aria-label="Close dialog"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>,
document.body
);
}
Step 3: Confirmation Dialog
"use client";
import { Modal } from "./Modal";
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
isLoading?: boolean;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
isLoading = false,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onClose} title={title} description={message} size="sm">
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
{cancelLabel}
</button>
<button
onClick={() => {
onConfirm();
onClose();
}}
disabled={isLoading}
className={`rounded-lg px-4 py-2 text-sm font-medium text-white ${
variant === "danger"
? "bg-red-600 hover:bg-red-700"
: "bg-blue-600 hover:bg-blue-700"
} disabled:opacity-50`}
>
{isLoading ? "Processing..." : confirmLabel}
</button>
</div>
</Modal>
);
}
Step 4: Usage
"use client";
import { useState } from "react";
import { Modal } from "./Modal";
import { ConfirmDialog } from "./ConfirmDialog";
export function Example() {
const [modalOpen, setModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<div>
<button onClick={() => setModalOpen(true)}>Open Modal</button>
<button onClick={() => setConfirmOpen(true)}>Delete Item</button>
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title="Edit Profile"
description="Make changes to your profile here."
>
<form>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
type="text"
className="mt-1 w-full rounded-lg border px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
/>
<div className="mt-4 flex justify-end">
<button
type="submit"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white"
>
Save Changes
</button>
</div>
</form>
</Modal>
<ConfirmDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => console.log("Deleted")}
title="Delete Item"
message="Are you sure? This action cannot be undone."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
Accessibility Checklist
- Focus is trapped inside the modal when open
- Escape key closes the modal
- Focus returns to the trigger element on close
- Background content is inert (aria-modal="true")
- Modal has aria-labelledby pointing to the title
- Close button has aria-label
- Body scroll is locked when modal is open
Need Accessible Web Applications?
We build inclusive web applications that meet WCAG guidelines and work for everyone. Contact us to discuss your project.