We've all encountered them: modals that look great but are a nightmare to use. You can't close them with the ESC key, tabbing through focus sends you to some hidden element in the background, and screen readers get completely lost. This isn't just a minor bug—it's a barrier that excludes users and creates a "clunky" feel that kills product adoption.
The truth is, many modals are built with only the visual experience in mind. But a truly professional component must be built for everyone. In this guide, we're going to tackle this problem head-on and build a modal from scratch that is as robust and accessible as it is good-looking.
This is about adopting a "Frontend-First" philosophy where the user interface is a growth engine, not a liability. By ensuring effortless adoption for all users, we build better products.
Let's build a solution.
What We're Building
A modal dialog that:
- Opens and closes smoothly
- Traps keyboard focus inside itself
- Closes on
ESCkey press - Manages accessibility attributes for screen readers
- Is fully reusable and ready for any project
Step 1: Laying the Foundation - JSX and State
First, we need to create the component structure and manage its visibility. We'll use the useState hook for this.
// Modal.jsx
import React, { useEffect, useRef } from 'react';
import './Modal.css'; // We'll create this next
const Modal = ({ isOpen, onClose, children }) => {
const modalRef = useRef(null);
// We'll add more logic here later
useEffect(() => {
// This is where the accessibility magic will happen!
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="modal-content" ref={modalRef}>
<button className="close-button" onClick={onClose} aria-label="Close dialog">
×
</button>
{children}
</div>
</div>
);
};
export default Modal;
Step 2: The Visual Layer - Basic CSS
A modal isn't a modal without proper styling. We need to overlay it on top of the rest of the page.
/* Modal.css */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
background-color: #f0f0f0;
}
Step 3: The Accessibility Engine - Focus Management
This is the part that transforms our component from "okay" to "professional." We need to do two things:
Trap focus inside the modal
Close the modal when the ESC key is pressed
We'll use useRef to get a direct reference to our modal container and useEffect to run our side effects.
Update the useEffect in your Modal.jsx:
// Inside the Modal component in Modal.jsx
useEffect(() => {
const modalElement = modalRef.current;
if (isOpen && modalElement) {
// Get all focusable elements inside the modal
const focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) { // Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else { // Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
const handleEscapeKey = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
// Focus the first element when the modal opens
firstElement?.focus();
// Add event listeners
document.addEventListener('keydown', handleTabKey);
document.addEventListener('keydown', handleEscapeKey);
// Cleanup function: remove event listeners when the modal closes or the component unmounts
return () => {
document.removeEventListener('keydown', handleTabKey);
document.removeEventListener('keydown', handleEscapeKey);
};
}
}, [isOpen, onClose]); // Dependencies for the effect
This code might look dense, but it's a classic pattern:
Focus Trap: When the user presses Tab on the last focusable element, we loop them back to the first one. Similarly, Shift+Tab on the first element goes to the last
Escape to Close: Listens for the Escape key and calls the onClose function
Cleanup: It's critical to remove event listeners to prevent memory leaks. The return () => { ... } function inside useEffect handles this
Step 4: Using Our New Accessible Modal
Now, let's see it in action from a parent component, like App.jsx.
// App.jsx
import React, { useState } from 'react';
import Modal from './Modal';
import './App.css';
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div className="app">
<h1>My Awesome App</h1>
<button onClick={() => setIsModalOpen(true)}>
Open Settings Modal
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<h2>User Settings</h2>
<p>This is a fully accessible modal! Try tabbing through or pressing Escape.</p>
<label htmlFor="username">Username:</label>
<input type="text" id="username" />
<br />
<button>Save Changes</button>
</Modal>
</div>
);
}
export default App;
This journey from a broken user experience to a seamless, inclusive one is what modern frontend development is all about. It's not just about fixing a component—it's about building a foundation of user trust through technical excellence.
Beyond focus traps and ARIA, what other accessibility pitfalls have you encountered in complex UIs? How did you solve them? Sharing these solutions helps us all raise the bar.
You've Built a Foundation
You've just built more than a modal; you've built a foundational piece of UI that understands its responsibility to all users. This "effortless adoption" mindset is what separates good frontend work from great, product-changing work.
Now it's your turn! Try extending this component: add a smooth fade-in animation with CSS, or prevent background page scrolling when the modal is open.I'd love to see what you create and am here to answer any questions!
At Hashbyt, we specialize in building accessible, high-performance user interfaces that drive adoption and reduce churn.
Top comments (0)