Loading challenge...
Preparing the challenge details...
Loading challenge...
Preparing the challenge details...
Build an accessible modal dialog component in React with focus trapping, keyboard navigation, backdrop click handling, and portal rendering. A common frontend interview question.
In this article, we'll build a modal component from scratch in React. Not a basic one that just shows and hides, a proper, production-ready modal that handles all the things you'd expect from a good modal. Let's dig in.
Before we write any code, let's think about what a modal actually needs to do:
Let's tackle each of these.
First, let's define what our component API should look like:
TSXcomponent.tsx1<ModalComponent 2 isModalOpen={isOpen} 3 onClose={handleClose} 4 closeOnOverlayClick={true} 5 closeOnEsc={true} 6> 7 <h1>Hello from the modal!</h1> 8 <button onClick={handleClose}>Close</button> 9</ModalComponent>
Clean and simple. The parent controls whether the modal is open, and the modal tells the parent when it wants to close. This is the "controlled component" pattern, and it's the right way to handle modals in React.
Here's the first trick: React Portals.
Normally, React renders components inside their parent's DOM node. But modals need to render at the top level of the DOM (usually as a direct child of <body>) so they can appear above everything else without worrying about z-index battles or overflow: hidden on parent containers.
TSXcomponent.tsx1import { createPortal } from 'react-dom'; 2 3const ModalComponent = ({ isModalOpen, onClose, children }) => { 4 if (!isModalOpen) return null; 5 6 return createPortal( 7 <div className="modal-root"> 8 <div className="modal-content"> 9 {children} 10 </div> 11 </div>, 12 document.body 13 ); 14};
createPortal takes two arguments: what to render and where to render it. We're rendering into document.body, which means our modal will always be at the root level of the DOM.
If you're using Next.js or any server-side rendering framework, there's a catch: document.body doesn't exist on the server. Try to access it during SSR and you'll get a hydration error.
The fix is to delay rendering until we're on the client:
TSXcomponent.tsx1const [mounted, setMounted] = useState(false); 2 3useEffect(() => { 4 setMounted(true); 5}, []); 6 7if (!mounted || !isModalOpen) return null;
This ensures the portal only renders after the component has mounted on the client. Thi is a simple thing, but easy to forget.
When a modal opens, the background page shouldn't scroll. This prevents that awkward experience where users accidentally scroll the page while trying to interact with the modal.
TSXcomponent.tsx1useEffect(() => { 2 if (!isModalOpen) return; 3 4 document.body.style.overflow = 'hidden'; 5 6 return () => { 7 document.body.style.overflow = ''; 8 }; 9}, [isModalOpen]);
We set overflow: hidden on the body when the modal opens, and clean it up when it closes. The cleanup function in useEffect handles this automatically.
Good UX means users can press Escape to close the modal. Let's add that:
TSXcomponent.tsx1useEffect(() => { 2 if (!isModalOpen) return; 3 4 const handleKeyDown = (e: KeyboardEvent) => { 5 if (e.key === 'Escape') { 6 onClose(); 7 } 8 }; 9 10 document.addEventListener('keydown', handleKeyDown); 11 12 return () => { 13 document.removeEventListener('keydown', handleKeyDown); 14 }; 15}, [isModalOpen, onClose]);
We add a global keydown listener when the modal opens and remove it when it closes. The event listener is on document, not the modal itself, so it works even if focus is elsewhere.
Clicking the dark overlay should close the modal. But clicking inside the modal content should not. Here's how:
TSXcomponent.tsx1<div className="modal-root" onClick={onClose}> 2 <div className="modal-content" onClick={(e) => e.stopPropagation()}> 3 {children} 4 </div> 5</div>
The outer container (the overlay) has the onClick handler to close. The inner content calls stopPropagation() to prevent the click from bubbling up. This way, clicks inside the modal stay inside.
Want to make this configurable? Add a prop:
TSXcomponent.tsx1<div 2 className="modal-root" 3 onClick={closeOnOverlayClick ? onClose : undefined} 4>
Now the consumer can decide whether clicking outside should close the modal.
This is the accessibility piece. When a modal opens, focus should move to the modal. When it closes, focus should return to the element that triggered it.
TSXcomponent.tsx1const modalRef = useRef<HTMLDivElement>(null); 2 3useEffect(() => { 4 if (!isModalOpen) return; 5 modalRef.current?.focus(); 6}, [isModalOpen]); 7 8// In the JSX: 9<div 10 className="modal-content" 11 ref={modalRef} 12 tabIndex={-1} 13> 14 {children} 15</div>
Setting tabIndex={-1} makes the div focusable programmatically (but not via Tab key), and we call focus() when the modal opens.
For a more complete solution, you'd also want to trap focus inside the modal and return focus to the trigger element on close. That's a bit more code, but the pattern is the same.
Screen readers need to know that this is a modal dialog. Add these ARIA attributes:
TSXcomponent.tsx1<div 2 className="modal-content" 3 role="dialog" 4 aria-modal="true" 5 tabIndex={-1} 6> 7 {children} 8</div>
role="dialog" tells assistive tech this is a dialog.aria-modal="true" indicates the rest of the page is inert while this is open.Here's everything put together:
TSXcomponent.tsx1'use client'; 2 3import React, { useEffect, useRef, useState } from 'react'; 4import { createPortal } from 'react-dom'; 5 6interface ModalComponentProps { 7 isModalOpen: boolean; 8 onClose: () => void; 9 children: React.ReactNode; 10 closeOnOverlayClick?: boolean; 11 closeOnEsc?: boolean; 12} 13 14const ModalComponent = ({ 15 isModalOpen, 16 onClose, 17 children, 18 closeOnOverlayClick = true, 19 closeOnEsc = true, 20}: ModalComponentProps) => { 21 const modalRef = useRef<HTMLDivElement>(null); 22 const [mounted, setMounted] = useState(false); 23 24 // SSR safety 25 useEffect(() => { 26 setMounted(true); 27 }, []); 28 29 // Scroll lock and keyboard handling 30 useEffect(() => { 31 if (!isModalOpen || !mounted) return; 32 33 document.body.style.overflow = 'hidden'; 34 35 const handleKeyDown = (e: KeyboardEvent) => { 36 if (closeOnEsc && e.key === 'Escape') { 37 onClose(); 38 } 39 }; 40 41 document.addEventListener('keydown', handleKeyDown); 42 43 return () => { 44 document.body.style.overflow = ''; 45 document.removeEventListener('keydown', handleKeyDown); 46 }; 47 }, [isModalOpen, onClose, closeOnEsc, mounted]); 48 49 // Focus management 50 useEffect(() => { 51 if (!isModalOpen || !mounted) return; 52 modalRef.current?.focus(); 53 }, [isModalOpen, mounted]); 54 55 if (!mounted || !isModalOpen) return null; 56 57 return createPortal( 58 <div 59 className="modal-overlay" 60 onClick={closeOnOverlayClick ? onClose : undefined} 61 > 62 <div 63 className="modal-content" 64 role="dialog" 65 aria-modal="true" 66 tabIndex={-1} 67 onClick={(e) => e.stopPropagation()} 68 ref={modalRef} 69 > 70 {children} 71 </div> 72 </div>, 73 document.body 74 ); 75}; 76 77export default ModalComponent;
Here's how you'd use it in a parent component:
TSXcomponent.tsx1const MyPage = () => { 2 const [isModalOpen, setIsModalOpen] = useState(false); 3 4 return ( 5 <div> 6 <button onClick={() => setIsModalOpen(true)}> 7 Open Modal 8 </button> 9 10 <ModalComponent 11 isModalOpen={isModalOpen} 12 onClose={() => setIsModalOpen(false)} 13 > 14 <h1>Hello!</h1> 15 <p>This is modal content.</p> 16 <button onClick={() => setIsModalOpen(false)}> 17 Close 18 </button> 19 </ModalComponent> 20 </div> 21 ); 22};
The CSS is straightforward:
CSSstyles.css1.modal-overlay { 2 position: fixed; 3 inset: 0; 4 background: rgba(0, 0, 0, 0.5); 5 z-index: 1000; 6 display: flex; 7 align-items: center; 8 justify-content: center; 9} 10 11.modal-content { 12 background: white; 13 padding: 24px; 14 border-radius: 8px; 15 min-width: 300px; 16 max-width: 90%; 17 outline: none; 18}
The inset: 0 is shorthand for top: 0; right: 0; bottom: 0; left: 0;—it makes the overlay cover the entire viewport.
This modal is solid, but there's more you could add:
But for most use cases, what we built here is exactly what you need.
Modals seem simple, but getting them right takes some thought. The key pieces are:
Now you know how it all works under the hood. Go build something cool with it.
Goal: Implement a modal component that can be used to display a modal.
Continue learning with these related challenges

Create an interactive image carousel in React with smooth slide transitions, navigation arrows, dot indicators, autoplay, and touch/swipe support for mobile devices.

Build a dynamic Tic Tac Toe game in React with customizable grid sizes, win detection algorithms, player turn management, and game reset functionality. Great for interviews.

Create an animated dice roller component in React with realistic rolling animations, random number generation, multiple dice support, and roll history tracking.

Create an interactive image carousel in React with smooth slide transitions, navigation arrows, dot indicators, autoplay, and touch/swipe support for mobile devices.

Build a dynamic Tic Tac Toe game in React with customizable grid sizes, win detection algorithms, player turn management, and game reset functionality. Great for interviews.

Create an animated dice roller component in React with realistic rolling animations, random number generation, multiple dice support, and roll history tracking.