This year, I decided to intentionally improve how I approach accessibility in UI components.
Instead of chasing a “perfect” implementation, I’m focusing on iterative improvements—build, test, learn, and refine. In my experience, this leads to better engineering outcomes than over-designing upfront.
I started with a modal, since it’s common, deceptively simple, and easy to get wrong.
First Iteration Requirements
For the first pass, these were my non-negotiables:
- Focus trapping (Tab / Shift + Tab)
- Focus restoration on close
- Move focus into the modal on open
- role="dialog" and aria-modal="true"
- Background inertness (inert / aria-hidden)
- Escape key handling
- Screen reader announcement timing
- Visible focus outlines via CSS
Implementation
import { useEffect, useRef, useState, type ReactNode } from "react";
import styles from "./style.module.css";
import { createPortal } from "react-dom";
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
}
function Modal({ children, onClose, open }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const bgFocusElement = useRef<HTMLButtonElement>(null);
useEffect(() => {
const keydownHandler = (e: KeyboardEvent) => {
const { key } = e;
const focusableElements = modalRef.current?.querySelectorAll(
'button, a, [href], input,textarea, select, [tabindex]:not([tabindex = "-1"])'
);
if (key === "Tab" && focusableElements?.length) {
const firstFocusableElement = focusableElements[0];
const lastFocusableElement =
focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusableElement) {
e.preventDefault();
(lastFocusableElement as HTMLButtonElement).focus();
}
} else {
if (document.activeElement === lastFocusableElement) {
e.preventDefault();
(firstFocusableElement as HTMLButtonElement).focus();
}
}
} else if (key === "Escape") {
onClose();
}
};
if (open) {
document.addEventListener("keydown", keydownHandler);
const focusableElements = modalRef.current?.querySelectorAll(
'button, a, [href], input,textarea, select, [tabindex]:not([tabindex = "-1"])'
);
bgFocusElement.current = document.activeElement as HTMLButtonElement;
if (focusableElements?.length) {
(focusableElements[0] as HTMLButtonElement)?.focus();
}
}
return () => {
if (open) {
document.removeEventListener("keydown", keydownHandler);
}
};
}, [open]);
useEffect(() => {
if (open) {
const rootElement = document.getElementById("root");
if (rootElement) {
rootElement.inert = true;
}
document.body.style.overflow = "hidden";
} else {
const rootElement = document.getElementById("root");
if (rootElement) {
rootElement.inert = false;
}
document.body.style.overflow = "";
if (bgFocusElement.current) {
bgFocusElement.current.focus();
bgFocusElement.current = null;
}
}
return () => {
const rootElement = document.getElementById("root");
if (rootElement) {
rootElement.inert = false;
}
document.body.style.overflow = "";
};
}, [open]);
if (!open) {
return null;
}
return createPortal(
<section
onClick={onClose}
aria-modal="true"
role="dialog"
className={styles.modalContainer}
ref={modalRef}
aria-label="Modal-Container"
>
<div
onClick={(e) => e.stopPropagation()}
className={styles.childrenContainer}
aria-label="Modal-Child-Container"
>
{children}
</div>
</section>,
document.body
);
}
function ModalParent() {
const [open, setOpen] = useState(false);
const openModal = () => {
setOpen(true);
};
const closeModal = () => {
setOpen(false);
};
return (
<article>
<button className={styles.btn} onClick={openModal}>
ModalParent
</button>
<div
style={{
width: 400,
height: 1300,
backgroundColor: "red",
}}
></div>
<Modal open={open} onClose={closeModal}>
<div>
<h1>Modal</h1>
<button className={styles.btn}>Modal Button</button>
<input type="text" />
</div>
</Modal>
</article>
);
}
export default ModalParent;
Styles (Focus Visibility Matters)
.modalContainer {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
}
.childrenContainer {
background: #fff;
padding: 1rem;
border-radius: 4px;
max-width: 768px;
width: 95%;
}
button:focus,
input:focus {
outline: 2px solid orange;
outline-offset: 4px;
}
Key Learnings
- Focus management is not optional
- Background inertness dramatically improves keyboard and screen reader behavior
- Native semantics + minimal ARIA works better than over-annotating
- Visual focus indicators matter more than expected
Hope anyone who reads it finds it useful and any suggestions for my next iteration is most welcome.
Top comments (0)