From harness-claude
Build accessible modal dialogs with focus trapping, escape dismissal, background inertness, and screen reader announcements. Covers native <dialog> and custom implementations.
How this skill is triggered — by the user, by Claude, or both
Slash command
/harness-claude:a11y-modal-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> Build accessible modal dialogs with focus trapping, escape dismissal, background inertness, and screen reader announcements
Build accessible modal dialogs with focus trapping, escape dismissal, background inertness, and screen reader announcements
<dialog> element when possible. Modern browsers support <dialog> with .showModal(), which provides built-in focus trapping, Escape dismissal, and backdrop styling.function Modal({ isOpen, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
return (
<dialog ref={dialogRef} onClose={onClose} aria-labelledby="dialog-title">
{children}
</dialog>
);
}
<dialog>), implement all accessibility requirements manually:function CustomModal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
// Focus the first focusable element in the modal
requestAnimationFrame(() => {
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
});
}
return () => {
// Return focus to trigger when closing
triggerRef.current?.focus();
};
}, [isOpen]);
if (!isOpen) return null;
return (
<>
<div className="backdrop" onClick={onClose} />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<h2 id="dialog-title">{title}</h2>
{children}
</div>
</>
);
}
function useFocusTrap(ref: RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return;
const container = ref.current;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [ref, active]);
}
inert attribute on the main content.useEffect(() => {
const main = document.getElementById('app-root');
if (isOpen && main) {
main.setAttribute('inert', '');
return () => main.removeAttribute('inert');
}
}, [isOpen]);
Close on Escape key press. This is expected behavior — users should never be trapped in a dialog without a keyboard exit.
Close on backdrop click for non-critical dialogs. For confirmation dialogs (role="alertdialog"), require explicit button interaction.
Use role="alertdialog" for confirmation prompts that require the user to acknowledge or make a choice. These should not close on backdrop click.
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-desc"
>
<h2 id="alert-title">Delete Account?</h2>
<p id="alert-desc">
This will permanently delete your account and all data. This cannot be undone.
</p>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Delete Account</button>
</div>
Return focus to the trigger element when the modal closes. Save a reference to document.activeElement before opening the modal and call .focus() on it after closing.
Prevent body scroll when modal is open.
body.modal-open {
overflow: hidden;
}
<dialog> vs. custom modal: The native <dialog> element with .showModal() provides focus trapping, Escape dismissal, ::backdrop styling, top-layer rendering, and inert behavior on background content — all for free. It is supported in all modern browsers. Use custom implementations only when you need behavior that <dialog> does not support.
Focus management sequence:
tabIndex={-1})aria-modal="true" vs. inert: aria-modal="true" tells screen readers that content behind the dialog is not interactive. However, some screen readers do not fully respect this. Adding inert to background content provides a robust fallback that works at the browser level.
Common mistakes:
aria-labelledby or aria-label)https://www.w3.org/WAI/ARIA/apd/patterns/dialog-modal/
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeMoves keyboard focus predictably for modals, drawers, SPA route changes, and dynamic content insertion to meet WCAG 2.1/2.2 focus order and visibility requirements.
Provides ARIA roles, states, and properties for interactive components. Use when building custom widgets, fixing screen reader issues, or implementing modals, tabs, accordions, menus, or dialogs accessibly.
Applies ARIA roles, states, and properties correctly to enhance assistive technology support for custom widgets. Includes patterns for accessible names, live regions, and state attributes.