📖 Developer Guide

How to Build a Modal in React — and What Stays Hard Afterwards

A React modal starts as a fixed div with a dark background behind it. Then a keyboard user tabs straight out of it into the page underneath, Escape closes it and throws away twenty minutes of typing, iOS scrolls the body while the dialog stays put, and the exit animation never plays because the component unmounted first. This guide builds the version that survives all four, and is honest about what is still hard when it does.

📅 Updated September 2026 ⏱ 13 min read ✍️ By Kompassify
The five layers of a React modal component - portal or native dialog, focus trap, dismissal rules, scroll lock and ARIA wiring - stacked over a dimmed page

Every React codebase eventually grows a <Modal>. It usually starts as a fixed div, a semi-transparent backdrop, and a z-index chosen by adding a zero to whatever the header uses. It looks finished after twenty minutes.

Then a keyboard user presses Tab four times and lands on a link in the page behind the overlay. A screen reader reads the whole page underneath as if nothing had opened. Escape discards a half-written comment with no warning. The page shifts four pixels to the left every time the modal opens. On an iPhone, the background scrolls while the dialog sits still. And the fade-out never plays, because the component unmounted the moment the state flipped.

None of that is exotic. It is the normal difficulty of a dialog, and it is why the browser eventually grew a native element for it. This guide builds a modal properly in React, in five layers, and then lists what stays hard once the component is done.

Key Takeaways

  • Reach for <dialog> first. showModal() gives you the top layer, inertness and Escape without writing any of it.
  • Focus is the whole feature. Move it in, keep it in, and give it back — the last part is the one everybody forgets.
  • z-index is not a layering strategy. The top layer sits above every stacking context; a portal only escapes the ones above it in the tree.
  • Route every dismissal through one handler. Escape, backdrop and the close button must share the unsaved-changes guard, or two of the three will bypass it.
  • Scroll lock needs the scrollbar gap. Compensate the width or the page jumps on every open.
  • A UI modal is not an onboarding modal. Same component, completely different lifecycle and owner.

What a Modal Actually Has to Do

Before any code, the specification. A modal that ships into a real design system has to:

Eight requirements, five of which are invisible on the developer's machine. Here is the shape of the component that satisfies them.

The five layers of a working modal 1 · RENDERING <dialog>.showModal() → top layer or createPortal → document.body owns: what it sits above 2 · FOCUS move in · cycle · restore remember the opener element owns: where the keyboard is 3 · DISMISSAL Escape · backdrop · close button one handler, one dirty-state guard owns: whether work is lost 4 · PAGE STATE scroll lock + scrollbar-width compensation background marked inert, not just dimmed owns: the page underneath 5 · ARIA WIRING role="dialog" · aria-modal="true" aria-labelledby → the heading inside owns: whether it is announced at all Skip any one layer and the modal still “works” — for a subset of your users, on a subset of your devices.

Each layer fixes a failure the others cannot see.


1. <dialog> or a Portal?

This is the first decision, and in 2026 it is a much easier one than it was three years ago. The native <dialog> element, opened with showModal(), is supported everywhere that matters and hands you four things for free that a hand-rolled overlay has to earn:

Behaviour <dialog> + showModal() createPortal + a div
Stacking Browser top layer — above every stacking context, no z-index at all Escapes ancestors only; still competes with anything else portalled to body
Background inertness Automatic — everything outside the dialog becomes inert You add inert to the app root yourself, and remember to remove it
Focus trapping Handled by the browser You write it, including the Shift+Tab wrap
Escape Fires a cancelable cancel event A keydown listener you add and remove
Backdrop ::backdrop, stylable, animatable A sibling element you position and z-index
Focus restoration Still yours to write Still yours to write

The one row that does not change is the last one, and it is the row users notice. Here is the hook, written against the native element.

useModal.ts
import { useCallback, useEffect, useRef } from 'react' export function useModal(open: boolean, onClose: () => void) { const ref = useRef<HTMLDialogElement>(null) const opener = useRef<HTMLElement | null>(null) useEffect(() => { const el = ref.current if (!el) return if (open && !el.open) { opener.current = document.activeElement as HTMLElement // remember who opened it el.showModal() // top layer + inert background } else if (!open && el.open) { el.close() } }, [open]) useEffect(() => { const el = ref.current if (!el) return // Escape fires 'cancel'; preventDefault() here to guard unsaved work const onCancel = (e: Event) => { e.preventDefault(); onClose() } // runs for every close path, including the browser's own const onCloseEvent = () => { opener.current?.focus({ preventScroll: true }) // give focus back opener.current = null } el.addEventListener('cancel', onCancel) el.addEventListener('close', onCloseEvent) return () => { el.removeEventListener('cancel', onCancel) el.removeEventListener('close', onCloseEvent) } }, [onClose]) return ref }

Two details are load-bearing. document.activeElement is captured before showModal() runs, because the browser moves focus as soon as the dialog opens. And the restore is hung off the element's own close event rather than a React cleanup, so it fires whichever route closed the dialog — state change, form submission with method="dialog", or the browser itself.

Do not use open as an attribute. Rendering <dialog open> puts the dialog in the page as a non-modal dialog: no top layer, no inert background, no focus trap, no Escape. It looks identical in a screenshot and is a completely different component. Always open it imperatively with showModal().

2. Focus: Trap It, Then Give It Back

If you are on the native element, the trap is already done. If you are on a portal — and plenty of design systems still are, for stacked-dialog reasons — you write it yourself, and it is worth seeing what you are signing up for.

useFocusTrap.ts — only needed for the portal route
const FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(',') export function useFocusTrap(ref: React.RefObject<HTMLElement>, active: boolean) { useEffect(() => { if (!active || !ref.current) return const root = ref.current const previous = document.activeElement as HTMLElement | null // query on every keypress: the DOM changes while the dialog is open const items = () => Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)) .filter(el => el.offsetParent !== null) // skip hidden ones const first = items()[0] ?? root first.focus({ preventScroll: true }) const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return const list = items() if (list.length === 0) return e.preventDefault() const head = list[0], tail = list[list.length - 1] if (e.shiftKey && document.activeElement === head) { e.preventDefault(); tail.focus() } else if (!e.shiftKey && document.activeElement === tail) { e.preventDefault(); head.focus() } } document.addEventListener('keydown', onKey) return () => { document.removeEventListener('keydown', onKey) previous?.focus({ preventScroll: true }) // the part people forget } }, [ref, active]) }

Three decisions in there are worth defending:


3. Dismissal, and the Unsaved-Changes Problem

A modal has at least four ways to close: the close button, Escape, a click on the backdrop, and a successful submit. Teams usually implement them in four places, and then discover that the "discard your changes?" guard only fires on one of them.

The fix is structural: every path calls the same requestClose(), and only requestClose() is allowed to decide.

Modal.tsx — one gate for every exit
function Modal({ open, onClose, dirty, titleId, children }) { const ref = useModal(open, requestClose) function requestClose() { if (dirty && !confirm('Discard your changes?')) return onClose() } // a click on the dialog itself is a click on the backdrop: // the visible panel is a child, so it stops the event by being the target const onBackdrop = (e: React.MouseEvent<HTMLDialogElement>) => { if (e.target === ref.current) requestClose() } return ( <dialog ref={ref} onClick={onBackdrop} aria-labelledby={titleId} className="modal"> <div className="modal-panel">{children}</div> </dialog> ) }
The backdrop-click trick that actually works. Comparing e.target === dialogRef.current is more reliable than measuring click coordinates against the panel's bounding box, because it cannot be fooled by a drag that starts inside the panel and ends on the backdrop — a text selection that overshoots, for example, which coordinate-based checks famously treat as "close and throw everything away".

A last rule that costs nothing: destructive dialogs should not close on backdrop click at all. If the outcome of the dialog is irreversible, an accidental click outside it should do nothing. Reserve light dismissal for dialogs where closing loses nothing.


4. Scroll Lock Without the Layout Jump

The naive lock is one line, and it is why so many products shift four pixels sideways every time a dialog opens: removing overflow removes the scrollbar, and the layout reflows into the space it occupied.

useScrollLock.ts
export function useScrollLock(active: boolean) { useEffect(() => { if (!active) return const body = document.body const prevOverflow = body.style.overflow // store, do not assume '' const prevPadding = body.style.paddingRight const gap = window.innerWidth - document.documentElement.clientWidth body.style.overflow = 'hidden' if (gap > 0) body.style.paddingRight = `${gap}px` // the anti-jump line return () => { body.style.overflow = prevOverflow body.style.paddingRight = prevPadding } }, [active]) }

Storing the previous values rather than resetting to an empty string matters as soon as two dialogs can be open in sequence, or a drawer and a dialog share the lock: the second cleanup would otherwise wipe state the first one still needs. If several components can lock scrolling, promote this to a counter in a shared module and only touch the body when the count crosses zero.

The residual problem is iOS Safari, where overflow: hidden on the body does not reliably stop momentum scrolling behind a fixed overlay. The usual workaround is to record window.scrollY, set the body to position: fixed with top: -{y}px, and restore the scroll position on unlock — which works, at the cost of a repaint and a brand-new edge case if the user rotates the device while the dialog is open. Test it on a real phone; the simulator lies about this one.

A modal dialog centred over a dimmed application, with the page behind it locked and inert

Same visual result whichever route you take — the difference is what happens on a keyboard and a phone.


5. Animation, Reduced Motion and the Unmount Race

You cannot animate an element that React has already removed. That single fact is behind most of the "my modal fades in but disappears instantly" bug reports.

The cheapest fix in 2026 is to stop asking React to manage presence at all and let CSS do it, using discrete transitions:

modal.css
dialog.modal { opacity: 0; translate: 0 8px; transition: opacity 180ms, translate 180ms, overlay 180ms allow-discrete, display 180ms allow-discrete; } dialog.modal[open] { opacity: 1; translate: 0 0; } /* the entry state the element animates FROM */ @starting-style { dialog.modal[open] { opacity: 0; translate: 0 8px; } } dialog.modal::backdrop { background: rgb(10 20 40 / 0); transition: background 180ms, overlay 180ms allow-discrete; } dialog.modal[open]::backdrop { background: rgb(10 20 40 / 0.55); } @starting-style { dialog.modal[open]::backdrop { background: rgb(10 20 40 / 0); } } @media (prefers-reduced-motion: reduce) { dialog.modal, dialog.modal::backdrop { transition-duration: 1ms; } }

The element stays in the DOM the whole time; the browser keeps it in the top layer until the transition finishes, because overlay is in the transition list. No state machine, no transitionend listener, no timeout fallback that fires while the tab is backgrounded and leaves the dialog stuck half-open.

The prefers-reduced-motion block is not decoration. Motion sensitivity is a real accessibility requirement, and a dialog that slides is one of the more provocative animations in a product — it is covered alongside the rest of the in-app guidance rules in our accessible onboarding guide.


What Stays Hard After It Works

The component above is genuinely finished. These are the problems that arrive afterwards, and none of them are solved by writing the modal better.

Problem Why it is hard What usually works
Stacked dialogs A confirm inside an edit dialog: two traps, two scroll locks, two Escape handlers competing A single stack in context; only the top entry listens. The top layer stacks correctly by open order, so the browser is already on your side.
Mobile keyboards The virtual keyboard shrinks the viewport and pushes a centred dialog off-screen Anchor to the bottom on small screens, use dvh units, and let the panel scroll internally
The back button Users expect Back to close a full-screen mobile dialog; it navigates away instead Push a history entry when a full-screen dialog opens, and close on popstate
Async close The dialog must stay open while a save is in flight, then close — or stay open and show an error A pending state that disables dismissal, and an explicit failure path that never silently closes
Deciding what deserves one Not a code problem at all — the third modal on a page is the one users learn to dismiss unread A budget: interruptions compete with each other, and the loser is whichever one appears second

That last row is where front-end work stops and product work starts. If you want the UX side of the decision — the six modal types, when a dialog is the wrong pattern, and the copy rules that make one dismissible without regret — that is our guide to what a modal is, and the interruption budget itself is covered in banner blindness.


A UI Modal Is Not an Onboarding Modal

The two look identical in Figma and behave nothing alike in production. This is the distinction that decides whether the work belongs in your React codebase at all.

UI modal Onboarding modal
Who triggers it The user, by clicking something The product, because of who the user is and what they have not done
Audience Everyone, identically A segment — new signups, one plan, one role, one unactivated cohort
State it needs None beyond the current interaction Per-user, persisted: seen, dismissed, completed, snoozed
Lives alone? Yes — one dialog, one decision No — step one of a sequence, with a resume point
Measured by Nothing; it either opened or it did not View rate, completion rate, and the activation metric behind it
How often the copy changes Rarely — it ships with the feature Constantly — it is content, not UI
Who owns it Front-end engineering Product, onboarding or customer success

Building the second on top of the first means shipping targeting rules, per-user persistence, sequencing, analytics and a copy-editing workflow — through your release cycle, every time someone rewrites a sentence. That is the reason welcome modals and product tours are usually configured rather than coded, and the same argument at larger scale is our build vs buy analysis.


Build It, or Configure It?

A clean split that holds up in practice:

Build Product surface dialogs

Confirmations, edit forms, pickers, destructive-action guards. They are part of the interface, they ship with the feature, and they belong in your design system.

Configure Guidance and adoption

Welcome screens, feature announcements, "you have not finished setting up billing". Content aimed at a segment, changed weekly, should not need a deploy.

Never The thing the user came for

If the main task only exists inside a dialog, it is a page. Modals are for decisions, not destinations.

Kompassify covers the middle column without touching the React codebase: modals, tooltips, hotspots, checklists and tours are pointed at elements in your live product from a visual editor, targeted by segment, and they report their own completion — so a change of wording is an edit, not a release. Our guide to building a product tour in React walks through the same trade-off for multi-step flows, where the state machine is considerably worse than a dialog's, and the React tooltip guide does it for the smallest component in the family.

Building an onboarding modal visually on a live React product instead of writing a component for guidance content

Same overlay problem, solved once — outside the release cycle.

Keep the component. Skip the content pipeline.

Kompassify lets product and onboarding teams add modals, tooltips, checklists and guided tours to a React app without shipping a release for every copy change — targeted by segment, with adoption data on each one. Free up to 100 monthly active users, plans from $129/month, GDPR-compliant and EU-hosted.

Start for free →

A Pre-Ship Checklist

Before the modal goes into the design system

  • Opened with showModal(), not the open attribute
  • Focus moves in on open and returns to the trigger on close — verified with the keyboard alone
  • Tab and Shift+Tab cannot reach the page behind it
  • The background is inert, not just dimmed — tested with a screen reader's virtual cursor
  • Named with aria-labelledby, pointing at a heading that exists
  • Escape, backdrop and close button all route through one handler, with one dirty-state guard
  • Destructive dialogs do not close on backdrop click
  • Scroll lock compensates the scrollbar width; no horizontal jump on open
  • Exit animation actually plays, and collapses to ~0ms under prefers-reduced-motion
  • Usable on a phone with the keyboard open, and Back closes a full-screen dialog

The One-Sentence Version

A React modal is a top-layer element, a focus round-trip, one dismissal handler and a scroll lock that compensates the scrollbar — build those four on top of native <dialog>, and keep onboarding content out of the component entirely.

Frequently Asked Questions

How do you create a modal in React?

A production React modal has five parts. First, a rendering strategy: either the native <dialog> element opened with showModal(), which puts the dialog in the browser's top layer, or createPortal into document.body when you need to support older rendering targets or want full control of the backdrop. Second, focus management: move focus into the dialog on open, keep Tab and Shift+Tab inside it, and return focus to the element that opened it on close. Third, dismissal rules: Escape and a backdrop click, both routed through the same close handler so unsaved-changes guards apply once. Fourth, background scroll lock that compensates for the scrollbar width so the page does not jump. Fifth, the ARIA wiring: role="dialog" with aria-modal="true", plus aria-labelledby pointing at the title. Skipping the second part is the most common defect, and it is the one that makes the modal unusable by keyboard.

Should I use the native dialog element or a React portal?

Use the native <dialog> element with showModal() unless you have a specific reason not to. It gives you the top layer for free, which means no z-index war with sticky headers and toasts; it gives you Escape handling, the ::backdrop pseudo-element, and inert-by-default content behind it. The reasons to reach for createPortal instead are narrow: you need the same component to render in a non-browser target, you need multiple stacked dialogs with custom layering rules the top layer does not give you, or your design requires a backdrop that animates in a way ::backdrop cannot express. Even then, portal-based modals still have to reimplement focus trapping, inertness and Escape by hand, which is exactly the work the native element removes.

How do you make a React modal accessible?

Four things, in order of how often they are missed. Give the dialog an accessible name with aria-labelledby pointing at the heading inside it, so screen reader users hear what opened. Move focus into the dialog when it opens — to the first meaningful control, or to the dialog container itself if there is no obvious target — and return it to the trigger on close, because focus landing back at the top of the document loses the user's place. Make the rest of the page inert, so a screen reader's virtual cursor cannot wander into content that is visually behind a dark overlay. And make sure Escape closes it. The native <dialog> element handles inertness, Escape and the top layer for you; the focus return is still yours to write.

Why does my page jump when a React modal opens?

Because the usual scroll lock — setting overflow: hidden on the body — removes the vertical scrollbar, and the content reflows into the width the scrollbar used to occupy. The fix is to measure the gap before you lock, with window.innerWidth minus document.documentElement.clientWidth, and add that number as padding-right on the body while the modal is open. Restore both values on close, and store the original inline styles rather than assuming they were empty, because two modals opening in sequence will otherwise clear each other's state. On iOS Safari the same approach is not enough on its own, since the body keeps scrolling under a fixed overlay; the common workaround is to record the scroll position, set the body to position: fixed with a negative top offset, and restore the scroll position on close.

How do you animate a React modal that unmounts?

You cannot animate an element that is already gone, so the component has to stay mounted until the exit animation finishes. There are three workable approaches. The simplest is to drive presence from CSS rather than from React: keep the dialog mounted, toggle a data attribute, and use transition-behavior: allow-discrete with @starting-style so the browser animates the element in and out of display: none. The second is to keep a small state machine — opening, open, closing, closed — and only unmount on the transitionend or animationend event, with a timeout fallback in case the event never fires. The third is a presence library that does this for you. Whichever you pick, gate the animation behind prefers-reduced-motion, and make sure a fast double-toggle cannot leave the component stuck in the closing state.

What is the difference between a React modal and an onboarding modal?

A UI modal is a synchronous interruption the user asked for: they clicked Delete, so a confirmation appears, and the interaction is over in two seconds. An onboarding modal is proactive guidance nobody asked for: it appears because a particular user has not done a particular thing yet, it is targeted by segment, it is often step one of a sequence, and it needs per-user dismissal state so it never shows twice. The component is nearly the same; everything around it is different. The hard parts of an onboarding modal are targeting, persistence, sequencing and being able to rewrite the copy on a Tuesday afternoon — none of which belong in a React component, which is why product teams usually configure that layer in a tool instead of shipping it through the front-end release cycle.