ReviseAlgo Logo

Machine Coding

Modal

Master building a custom Modal component in React, covering React Portals, focus trapping, keyboard shortcuts, backdrop clicks, and enter/exit animations.

Last Updated: July 15, 2026 13 min read

1. Learning Objectives

In this challenge, you will build a custom Modal component from scratch. By the end of this guide, you will be able to:

  • Use ReactDOM.createPortal to render modal content outside the parent DOM hierarchy.
  • Implement focus trapping to keep keyboard navigation inside the modal.
  • Handle keyboard shortcuts (ESC to close).
  • Close the modal when the user clicks the backdrop overlay.
  • Add CSS enter/exit animations for smooth transitions.

2. Overview

A Modal is one of the most frequently asked machine coding questions because it combines multiple React concepts: portals for DOM placement, refs for focus management, effects for event listener cleanup, and conditional rendering with animations. Building one from scratch demonstrates mastery over React's escape hatches.

3. Why This Challenge Matters

Custom modals teach critical UI interaction patterns:

  • React Portals: Rendering outside the parent DOM tree solves z-index and overflow issues that plague modals rendered inline.
  • Accessibility: Focus trapping ensures keyboard users cannot tab behind the modal, which is a WCAG requirement for accessible overlays.

4. Real-World Analogy

Think of a Modal like a pop-up window at a bank counter:

  • The Portal: The pop-up window is not part of the regular queue line — it appears separately in front of everything. This is why modals use portals to render outside the normal DOM tree.
  • The Backdrop: A frosted glass partition separates the window from the rest of the bank. Tapping the partition closes the window — this is the backdrop click handler.
  • Focus Trapping: Once you step up to the window, you can only interact with the teller until the conversation is done. You cannot walk away mid-transaction — this is focus trapping.

5. Core Concepts

Below is a comparison of inline rendering versus portal rendering for overlays:

Property Inline Rendering Portal Rendering
DOM Placement Modal HTML is nested inside the parent component's DOM tree. Modal HTML is rendered into a separate DOM node (e.g., #modal-root).
Z-index Issues Parent overflow: hidden or stacking contexts can clip or hide the modal. No clipping. The modal sits outside all parent stacking contexts.
Event Bubbling Events bubble through the React tree normally. Events still bubble through the React tree (not DOM tree), so portal children behave as expected in React.

6. Syntax & API Reference

This example shows how to create a portal-based modal with ESC key handling:

7. Visual Diagram

This diagram shows how a portal renders modal content outside the parent DOM tree:

8. Live Example — Full Working Code

Below is a complete HTML page demonstrating a portal-based modal with backdrop click, ESC key, and animations:

What just happened? The Modal component renders its content into #modal-root using ReactDOM.createPortal. Clicking the backdrop closes the modal. Pressing ESC closes it via a keydown listener. The e.stopPropagation() call on the modal box prevents clicks inside the modal from triggering the backdrop's close handler.

9. Interactive Playground

Try It Yourself Challenges:

  1. Implement full focus trapping: when the user presses Tab on the last focusable element inside the modal, cycle focus back to the first focusable element.
  2. Add an exit animation: when closing the modal, play a fade-out animation before removing it from the DOM.

10. Common Mistakes

Mistake Why it happens Wrong Correct
Clicking inside modal closes it The click event on the modal content bubbles up to the backdrop's onClick handler. <div onClick={onClose}>
<div>Content</div>
</div>
<div onClick={onClose}>
<div onClick={e =>
e.stopPropagation()}>
Content
</div>
</div>
Not cleaning up event listeners Adding a keydown listener for ESC without removing it in the useEffect cleanup causes memory leaks and stale closures. useEffect(() => {
document.addEventListener(
'keydown', handler);
});
useEffect(() => {
document.addEventListener(...);
return () =>
document.removeEventListener(...);
}, [isOpen]);

11. Best Practices

  • Use portals for overlays: Always render modals into a separate DOM node to avoid z-index and overflow clipping issues.
  • Lock body scroll: Set document.body.style.overflow = 'hidden' when the modal opens and restore it on close.
  • Add ARIA attributes: Use role="dialog", aria-modal="true", and aria-labelledby for screen reader accessibility.
  • Auto-focus the modal: Set tabIndex={-1} on the modal container and call .focus() on mount to establish focus context.

12. Browser Compatibility/Requirements

React portals (ReactDOM.createPortal) are supported in React 16+ and work on all modern browsers.

13. Interview Questions

Q1: Why do React portals preserve event bubbling through the React tree even though the DOM node is elsewhere?

Answer: React maintains its own synthetic event system that is based on the React component tree, not the DOM tree. A portal child is still a child in the React tree, so events bubble up through React's virtual hierarchy. This means a click inside a portal can still trigger an onClick on a parent React component, even though the DOM elements are in different branches.

Q2: What is focus trapping and why is it important for modals?

Answer: Focus trapping confines keyboard focus (Tab and Shift+Tab) to elements within the modal while it is open. This prevents users from tabbing to elements behind the modal overlay, which would be visually confusing and violates WCAG accessibility guidelines. When the modal closes, focus should be returned to the element that triggered it.

14. Debugging Exercise

Identify why clicking the "Cancel" button inside the modal closes the modal AND triggers the delete action:

View Solution

Diagnosis: Even though the modal is rendered via a portal (in a different DOM location), React's synthetic event system bubbles events through the React component tree. The Cancel button's click bubbles up through the React tree to the parent <div onClick={deleteItem}>, triggering the delete.

Fix: Stop propagation on the modal content, or restructure the parent so the delete handler is not a parent of the modal component:

15. Practice Exercises

Exercise 1: Stacked modals

Implement a system that supports multiple stacked modals. Opening a second modal from within the first should dim the first modal further. Pressing ESC should close only the topmost modal.

16. Scenario-Based Challenge

The unsaved changes confirmation challenge:

Your form modal contains unsaved changes. When the user clicks the backdrop or presses ESC, instead of closing immediately, show a nested confirmation dialog: "You have unsaved changes. Discard?" with "Keep Editing" and "Discard" buttons. Describe how you would manage the two modal states and prevent the outer modal from closing until the user explicitly confirms.

17. Quick Quiz

Q1: What does e.stopPropagation() prevent in the context of a modal backdrop?

A) It prevents the modal from rendering

B) It prevents clicks inside the modal content from bubbling to the backdrop's onClick handler

C) It prevents the browser from navigating

Answer: B — Without stopPropagation, clicking a button inside the modal would also trigger the backdrop's close handler, because the click event bubbles up through the DOM.

18. Summary & Key Takeaways

  • Use ReactDOM.createPortal to render modals outside the parent DOM hierarchy.
  • Stop event propagation on the modal content box to prevent backdrop close on internal clicks.
  • Add ESC key handling and clean up event listeners in useEffect.
  • Lock body scroll and set ARIA attributes for accessibility.
  • Auto-focus the modal container on open and return focus to the trigger on close.

19. Cheat Sheet

Operation Syntax Pattern
Create portal ReactDOM.createPortal(jsx, document.getElementById('modal-root'))
Stop propagation onClick={e => e.stopPropagation()}
ESC handler if (e.key === 'Escape') onClose()
Lock scroll document.body.style.overflow = 'hidden'
---