import { useEffect, useRef } from 'react';
const TABBABLE = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
export function useFocusTrap(active) {
const containerRef = useRef(null);
useEffect(() => {
if (!active) return undefined;
const container = containerRef.current;
if (!container) return undefined;
const previouslyFocused = document.activeElement;
const tabbables = () => Array.from(container.querySelectorAll(TABBABLE));
const first = tabbables()[0];
if (first) first.focus();
else container.focus();
function onKeyDown(event) {
if (event.key !== 'Tab') return;
const nodes = tabbables();
if (nodes.length === 0) return;
const firstNode = nodes[0];
const lastNode = nodes[nodes.length - 1];
if (event.shiftKey && document.activeElement === firstNode) {
event.preventDefault();
lastNode.focus();
} else if (!event.shiftKey && document.activeElement === lastNode) {
event.preventDefault();
firstNode.focus();
}
}
container.addEventListener('keydown', onKeyDown);
return () => {
container.removeEventListener('keydown', onKeyDown);
if (previouslyFocused && previouslyFocused.focus) {
previouslyFocused.focus();
}
};
}, [active]);
return containerRef;
}
import { useEffect, useRef } from 'react';
export function useEscapeKey(handler, enabled = true) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
if (!enabled) return undefined;
function onKeyDown(event) {
if (event.key === 'Escape') {
handlerRef.current(event);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
import { useEffect, useId } from 'react';
import ReactDOM from 'react-dom';
import { useFocusTrap } from './useFocusTrap';
import { useEscapeKey } from './useEscapeKey';
export function Modal({ isOpen, onClose, title, children }) {
const containerRef = useFocusTrap(isOpen);
const titleId = useId();
useEscapeKey(onClose, isOpen);
useEffect(() => {
if (!isOpen) return undefined;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [isOpen]);
if (!isOpen) return null;
const root = document.getElementById('modal-root');
if (!root) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-panel"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={containerRef}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<header className="modal-header">
<h2 id={titleId}>{title}</h2>
<button type="button" aria-label="Close dialog" onClick={onClose}>
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>,
root
);
}
This snippet builds an accessible modal dialog in React by composing three small, focused pieces: a reusable focus-trap hook, an Escape-key hook, and the Modal component that renders through a portal. Splitting the concerns keeps each hook testable in isolation and lets the component read as a declarative composition rather than a tangle of event listeners.
The useFocusTrap hook returns a ref that gets attached to the dialog container. When the modal opens it records document.activeElement so focus can be restored on close, then focuses the first tabbable element inside the container. Its keydown handler intercepts Tab and Shift+Tab, querying all tabbable descendants with a standard selector and wrapping focus from the last element back to the first (and vice versa). This is what prevents keyboard users from tabbing out of the dialog into the inert page behind it — a hard requirement for an accessible modal.
The useEscapeKey hook is intentionally tiny: it attaches a document-level keydown listener that invokes the supplied callback when Escape is pressed, and it is gated by an enabled flag so the listener is only active while the modal is open. Keeping the latest handler in a ref avoids re-subscribing on every render while still calling the current callback.
In Modal component, ReactDOM.createPortal renders the overlay into a #modal-root node outside the main app tree, which sidesteps z-index and overflow stacking problems from ancestor containers. The dialog wires up role="dialog", aria-modal="true", and aria-labelledby so assistive technology announces it correctly. An effect toggles document.body.style.overflow to lock background scrolling while open. Clicking the overlay closes the modal, but onMouseDown on the panel with stopPropagation prevents a drag that ends on the backdrop from triggering an accidental close.
The trade-off worth noting is that this hand-rolled trap does not make the rest of the page truly inert for screen readers; a fully robust solution would also apply aria-hidden or the inert attribute to sibling content. Even so, this pattern covers the common cases — restore focus, trap focus, close on Escape, close on overlay click — with minimal dependencies, and is a solid base to reach for when a full dialog library is overkill.
Related snips
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.