Modals Are a Routing Problem: Rethinking Dialogs in 2026
Most modal bugs aren't CSS bugs. They're routing bugs, focus bugs, and state bugs pretending to be design decisions. Here's how to actually ship dialogs that behave.

A user opens a modal on your product page, shares the URL with a teammate, and the teammate lands on... the product page. No modal. That's not a design bug. That's a routing bug, and it's the single most common reason modal UX quietly rots on mature products.
We've spent the last few years auditing dialogs across client codebases, and the pattern is consistent: teams treat modals as visual components when they're actually pieces of application state that deserve URLs, history entries, and the same care you'd give a page.
The four things every modal must handle
Before we get into implementation, here's the checklist we run against every dialog we ship. If any of these fail, the modal is broken — even if it looks pixel-perfect in Figma.
- Deep-linkability. Can someone paste a URL and land inside the modal?
- Back button. Does browser back close the modal instead of leaving the page?
- Focus trap and return. Is focus locked inside, and does it return to the trigger on close?
- Escape and scrim behavior. Does Escape close it? Does the scrim? Should it?
Every one of these has a wrong answer that ships to production more often than we'd like to admit. The first two are routing problems. The last two are focus and interaction problems. Most teams solve one category and ignore the other.
Why component-tree modals fail at scale
The default React tutorial teaches you this:
function ProductPage() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Edit</button>
{open && <EditModal onClose={() => setOpen(false)} />}
</>
);
}
This works for a demo. It breaks the moment your product grows past three screens. The modal's existence is trapped inside a component's local state, which means:
- The URL doesn't change, so you can't share the state.
- Refreshing the page loses the modal.
- Analytics can't distinguish "viewed product" from "opened edit dialog."
- Back button navigates away from the whole page instead of just closing the dialog.
- Multiple modals fight for z-index because there's no central authority.
On mobile, that back-button issue is a UX disaster. Android users hit hardware back expecting the modal to dismiss. Instead they get thrown out of the flow entirely.
The mental model shift
Stop thinking about modals as components. Start thinking about them as routes that happen to render on top of another route. Next.js formalized this with parallel routes and intercepting routes, but the idea predates the framework and works anywhere.
A modal is a URL segment. /products/42 shows a product. /products/42/edit shows the same product with an edit dialog layered on top. When you close the dialog, you navigate back to /products/42. The browser handles history. The URL is shareable. The back button just works.
Implementing URL-driven modals
Here's a stripped-down pattern we use with React Router or TanStack Router. The same shape works in Next.js App Router with parallel routes, and in Vue Router with named views.
// routes.tsx
<Route path="/products/:id" element={<ProductPage />}>
<Route path="edit" element={<EditModal />} />
<Route path="delete" element={<DeleteConfirm />} />
</Route>
// ProductPage.tsx
function ProductPage() {
const navigate = useNavigate();
const { id } = useParams();
return (
<>
<ProductDetails id={id} />
<button onClick={() => navigate(`/products/${id}/edit`)}>
Edit
</button>
<Outlet />
</>
);
}
// EditModal.tsx
function EditModal() {
const navigate = useNavigate();
const close = () => navigate('..', { relative: 'path' });
return <Dialog open onClose={close}>...</Dialog>;
}
Notice what disappeared: no useState, no prop drilling, no event bus to coordinate closing dialogs from three levels deep. The URL is the source of truth. Everything else is a projection.
For Next.js, the equivalent uses intercepting routes with (.)edit folders — same mental model, different syntax. Vendors document this well; we won't reproduce it here.
Focus management, done properly
Routing handles half the problem. The other half is what happens inside the modal once it opens. Screen reader users, keyboard users, and anyone with motor impairment will notice immediately if you get this wrong.
The non-negotiables:
- Focus moves into the modal on open, ideally to the first interactive element or a labelled heading.
- Tab and Shift+Tab cycle within the modal only.
- Escape closes the modal.
- Focus returns to the triggering element on close.
- The modal has
role="dialog"andaria-modal="true", witharia-labelledbypointing at its heading.
Writing this from scratch is a bad idea in 2026. Use a library that has already dealt with the edge cases: Radix UI, React Aria, or the native <dialog> element with a polyfill for older browsers. The native <dialog> with showModal() gets you focus trapping, Escape handling, and the top-layer stacking context for free — no more z-index wars.
function EditModal() {
const ref = useRef<HTMLDialogElement>(null);
const navigate = useNavigate();
useEffect(() => {
ref.current?.showModal();
}, []);
return (
<dialog
ref={ref}
onClose={() => navigate('..', { relative: 'path' })}
aria-labelledby="edit-title"
>
<h2 id="edit-title">Edit product</h2>
{/* form */}
</dialog>
);
}
The return-focus trap
One subtle bug: if the modal was opened by clicking a button that no longer exists after close (say, you deleted the item), where does focus go? The safe default is a stable landmark — the page heading, the main content container. Don't let focus drop to <body>, because keyboard users then have to Tab from the start of the page to get back to where they were.
Scrim, escape, and the "are you sure" problem
Should clicking the scrim close the modal? It depends, and this is one of those places design tokens can't save you — it's a policy decision per dialog type.
Our rule of thumb:
- Informational or navigational modals (a product quick view, a filter panel): scrim click and Escape both close. Low cost of accidental dismissal.
- Forms with unsaved input: scrim click does nothing or triggers a confirm. Escape triggers a confirm. Users lose work when you're too eager to close.
- Destructive confirmations (delete, cancel subscription): scrim click does nothing. Escape closes because it maps to "cancel" and cancelling is safe.
Getting this wrong turns your polished dialog into a rage-inducing form-loss machine. We've seen conversion drops on checkout upsell modals purely because the scrim was too aggressive. Test with real users, not with your team who knows to be careful.
Motion that respects intent
A modal opening should feel like a layer being placed on top of the page, not a dropdown flying in from the corner. Keep the motion subtle:
- 150 – 250ms duration for open, slightly faster for close.
- Opacity fade on the scrim, opacity + small scale (0.96 → 1) or opacity + small translate on the dialog.
- Respect
prefers-reduced-motionand drop transforms entirely — just fade the opacity.
@media (prefers-reduced-motion: reduce) {
.dialog { animation: fade 120ms ease; transform: none; }
}
Avoid springs and bounces on modals. They read as playful, which is fine for a mascot, wrong for a delete confirmation.
The mobile question
On phones, a centered dialog often shouldn't be a dialog at all. It should be a bottom sheet. The interaction model on mobile is thumb-first, and reaching a centered X button in the top-right corner is genuinely painful on a 6.7-inch screen.
We usually ship the same routed state with two presentations: dialog on md: and up, bottom sheet below. The routing, focus management, and dismissal logic stay identical. Only the visual container swaps. This is one of the biggest quiet wins we've had on mobile conversion — not the sheet itself, but treating mobile dismissal (swipe down, tap outside, back button) as first-class rather than an afterthought.
Where we'd start
If you're inheriting a codebase full of useState-driven modals, don't rewrite them all on Monday. Pick the modal that gets the most traffic — usually login, checkout, or a primary create form — and migrate that one to a URL-driven pattern with proper focus handling. Ship it, watch your analytics for direct-link traffic to the modal URL, and use that data to prioritize the rest.
Audit the four checklist items on your top five dialogs this week. Most teams find at least three failures they can fix in a day. If you want a second set of eyes on your dialog patterns or your broader design system, our design and engineering team does this kind of audit regularly — but honestly, the checklist above will get you 80% of the way there on your own.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Form Field Errors: Why Yours Are Failing Users and How to Fix Them
Inline validation looks helpful on the Figma frame and hostile in production. Here's how to design error states that guide users through a form instead of punishing them for typing.

Toast Notifications Are Broken: A Better Pattern for Async Feedback
Toasts fail screen readers, disappear before users can read them, and stack into unreadable towers. Here's the async feedback pattern we ship instead.

Design Tokens That Survive Contact With Engineering
Most design token systems die somewhere between Figma and production. Here's how we structure tokens so they actually round-trip between designers and engineers without becoming a second source of truth nobody trusts.
