#React#Modal Component

Modal Component

Build an accessible modal dialog component in React with focus trapping, keyboard navigation, backdrop click handling, and portal rendering. A common frontend interview question.

By Pratik Rai

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.

The task

Goal: build a reusable modal dialog component.

Requirements

  1. Render the modal above all other page content, regardless of where in the tree it is opened from.
  2. Open and close it from a parent component, so the parent owns the open state.
  3. Close on Escape, on a click of the backdrop, and from a close button inside the dialog.
  4. Do not close when the click lands inside the dialog itself.
  5. Prevent the page behind from scrolling while the modal is open.
  6. Move focus into the dialog when it opens and return it to the trigger when it closes.

Stretch goals

  • Keep Tab focus inside the dialog while it is open.
  • Support stacked modals.
  • Animate entry and exit.

Hints

  1. Use a portal. A modal nested inside whatever opened it inherits that parent's overflow, transform and stacking context, any of which can clip it.
  2. Rendering a portal needs document, which does not exist while the server renders, so guard the first paint rather than reaching for document unconditionally.
  3. For the backdrop click, compare the event target with the element the handler is attached to. A click that started inside the dialog should not close it.
  4. Remember the element that had focus before opening, so you have somewhere to send focus back to on close.

The Problems a Modal Needs to Solve

Before we write any code, let's think about what a modal actually needs to do:

  • Render on top of everything. The modal should appear above all other content, regardless of where it's triggered from in the component tree.
  • Block background scrolling. When the modal is open, the user shouldn't be able to scroll the page behind it.
  • Close on Escape key. This is standard UX—pressing Escape should dismiss the modal.
  • Close on overlay click. Clicking the dark backdrop should close the modal (usually).
  • Trap focus. For accessibility, focus should stay inside the modal while it's open.
  • Be accessible. Proper ARIA attributes so screen readers understand what's happening.

Let's tackle each of these.

Step 1: The Basic Structure

First, let's define what our component API should look like:

TSXcomponent.tsx
1<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.

Step 2: Using Portals

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.tsx
1import { 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.

Step 3: Handling SSR

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.tsx
1const [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.

Step 4: Scroll Locking

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.tsx
1useEffect(() => { 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.

Step 5: Keyboard Handling (Escape to Close)

Good UX means users can press Escape to close the modal. Let's add that:

TSXcomponent.tsx
1useEffect(() => { 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.

Step 6: Click Outside to Close

Clicking the dark overlay should close the modal. But clicking inside the modal content should not. Here's how:

TSXcomponent.tsx
1<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.tsx
1<div 2 className="modal-root" 3 onClick={closeOnOverlayClick ? onClose : undefined} 4>

Now the consumer can decide whether clicking outside should close the modal.

Step 7: Focus Management

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.tsx
1const 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.

Step 8: ARIA Attributes

Screen readers need to know that this is a modal dialog. Add these ARIA attributes:

TSXcomponent.tsx
1<div 2 className="modal-content" 3 role="dialog" 4 aria-modal="true" 5 tabIndex={-1} 6> 7 {children} 8</div>

The Final Component

Here's everything put together:

TSXcomponent.tsx
1'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;

Using the Modal

Here's how you'd use it in a parent component:

TSXcomponent.tsx
1const 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};

Styling

The CSS is straightforward:

CSSstyles.css
1.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.

What We Didn't Cover

This modal is solid, but there's more you could add:

  • Focus trapping: Keep Tab key cycling through focusable elements inside the modal only.
  • Animations: Fade in/out, slide up, scale—whatever fits your design.
  • Stacking modals: What happens if you open a modal from inside a modal? (Usually you shouldn't, but sometimes you need to.)
  • Different sizes: Small, medium, large, full-screen variants.

But for most use cases, what we built here is exactly what you need.

Wrapping Up

Modals seem simple, but getting them right takes some thought. The key pieces are:

  1. Portals to render at the top of the DOM
  2. SSR handling for Next.js/server rendering
  3. Scroll locking to prevent background scrolling
  4. Keyboard handling for Escape key
  5. Click outside to close
  6. Focus management for accessibility
  7. ARIA attributes for screen readers

Now you know how it all works under the hood. Go build something cool with it.

What interviewers look for

  • Portal or not? This is usually the first question. If the modal is rendered inline, ask yourself what happens when a parent has overflow: hidden.
  • Does Escape work? It is one listener, and forgetting it is the most common omission.
  • Does the backdrop click distinguish inside from outside? Attaching the close handler to a wrapper that contains the dialog closes it on every click, including on the content.
  • What happens to focus? Opening a dialog and leaving focus on the page behind means a keyboard user has no idea anything happened. Returning focus on close is the half people forget.
  • Is scroll locked? A background that scrolls under an open modal is an obvious tell that the component was built by eye.

Goal: Implement a modal component that can be used to display a modal.

Frequently asked questions

Is building a modal a common frontend interview question?
Yes — it is a staple of UI machine-coding rounds because almost every application needs one and almost every naive implementation gets the details wrong. The visible part takes minutes; the accessibility and focus behaviour is what the question is really about.
What do interviewers look for in a modal implementation?
Whether it renders through a portal rather than nesting inside whatever component opened it, whether Escape closes it, whether clicking the backdrop closes it while clicking the panel does not, and whether background scrolling is locked while it is open. Focus handling is the deepest part: focus should move into the dialog on open and return to the trigger on close.
Why does a modal need a portal?
Because a modal is visually on top of the whole page, but in the DOM it would otherwise sit inside a parent that may have its own overflow, transform or stacking context — any of which can clip it or trap it behind other content. Rendering into the document body sidesteps all of that and keeps the styling independent of where the modal was opened from.
What accessibility details are expected in a modal?
The container should be marked as a dialog and declared modal, it should be labelled by its own heading, and focus should be kept inside it while it is open so that tabbing does not wander into the page behind. Escape must close it. These are the follow-ups interviewers reach for once the basic open and close works.

Related Challenges

Continue learning with these related challenges

View All
React

Image Carousel

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

React · JavaScriptPratik Rai ·

React

Dynamic Tic Tac Toe

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.

React · JavaScriptPratik Rai ·

React

Dice Roller

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

React · JavaScriptPratik Rai ·