From harness-claude
Builds accessible modal dialogs with focus trapping, Escape dismissal, backdrop inertness, and screen reader announcements using native <dialog> or custom React components.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Build accessible modal dialogs with focus trapping, escape dismissal, background inertness, and screen reader announcements
Applies ARIA roles, states, and properties correctly to custom interactive widgets for enhanced screen reader support when native HTML semantics are insufficient.
Provides native HTML dialog patterns for Rails with Turbo and Stimulus to build accessible modals, confirmations, alerts, and overlay UIs.
Implements WCAG 2.1/2.2 compliance, ARIA patterns, keyboard navigation, focus management, and accessibility testing for web components.
Share bugs, ideas, or general feedback.
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/