All articles
Design & UXAugust 22, 2026 7 min read

Modals Are a Routing Problem: Stop Treating Dialogs Like Floating Divs

Most modal bugs aren't styling issues. They're routing issues in disguise. Here's how to think about dialogs as URL state, and what that fixes.

Every product team eventually files the same bug: "the modal reopens when I hit back", or "we can't share a link to this dialog", or "the modal steals focus and screen readers get lost". The fix isn't a bigger z-index or a smarter portal. The fix is treating the modal as a route.

Dialogs are one of the few UI surfaces where design, engineering, accessibility, and analytics all overlap — and most codebases quietly get all four wrong at once. Here's the model we've been shipping with, and why it holds up.

The Bug Report Behind This Post

On a recent e-commerce build, we inherited a checkout flow with roughly a dozen modals: address edit, coupon entry, gift wrap, delivery slot, payment method, and so on. The team had wired them all as useState(false) booleans inside their parent components.

Symptoms were predictable:

  • Back button closed the whole page instead of the dialog.
  • Support couldn't reproduce customer issues because the URL never reflected what the customer was actually looking at.
  • Analytics undercounted modal opens by ~30% because the tracking was on the toggle function, which double-fired.
  • The address edit modal reset its form whenever a parent re-rendered.
  • Screen reader testing revealed focus escaping the dialog on Safari iOS about one time in five.

None of these were CSS problems. They were state problems. Specifically, they were state living in the wrong place.

The Mental Model: Dialogs Are Locations

Here is the shift. A modal is not a component that is visible or hidden. A modal is a place a user can be. Places have URLs. Places show up in history. Places can be linked to, refreshed, and shared.

Once you accept that, a lot of decisions get easier:

  • Should back close the modal? Yes, because navigating back should move you to the previous place.
  • Should the URL change? Yes, so support can ask "what does the address bar say?"
  • Should refresh keep the modal open? Almost always yes — otherwise the user loses context.
  • Should analytics fire on open? Yes, and it's just a pageview.

The design team's job doesn't change much. The engineering team's job gets simpler because there's now one source of truth: the URL.

When A Modal Should Not Be A Route

Route-based modals are the default, not the law. Skip the route when:

  • The dialog is ephemeral and non-destructive (a tooltip-ish confirm that lasts under two seconds).
  • The dialog is a system prompt the user did not request (session timeout warnings, for example).
  • The dialog is nested inside a wizard that already owns the URL.

For everything else — edit forms, detail views, filters, media viewers, settings panels — route it.

Implementing It Without a Framework Rewrite

You don't need to adopt a specific router flavour to get this right. The pattern works with Next.js parallel routes, React Router, TanStack Router, SvelteKit, Nuxt, or plain query strings. The choice is between path segments and query parameters, and it matters.

Use a path segment when the modal represents a distinct resource: /orders/1234/refund. Use a query parameter when the modal is a mode of the current page: /orders/1234?dialog=cancel.

Here's the minimal pattern in React using query state. It's boring on purpose.

import { useSearchParams } from 'next/navigation';

export function useDialog(name: string) {
  const params = useSearchParams();
  const router = useRouter();
  const isOpen = params.get('dialog') === name;

  const open = () => {
    const next = new URLSearchParams(params);
    next.set('dialog', name);
    router.push(`?${next.toString()}`, { scroll: false });
  };

  const close = () => {
    const next = new URLSearchParams(params);
    next.delete('dialog');
    router.push(`?${next.toString()}`, { scroll: false });
  };

  return { isOpen, open, close };
}

Now the dialog component itself doesn't own state — it reads it.

function CancelOrderDialog() {
  const { isOpen, close } = useDialog('cancel');
  return (
    <Dialog open={isOpen} onOpenChange={(v) => !v && close()}>
      {/* content */}
    </Dialog>
  );
}

The back button now works. Refresh works. Sharing works. And crucially, you can open the dialog from anywhere in the app by pushing the right URL.

Nested and Stacked Dialogs

Stacks are where naive implementations fall apart. If you use dialog=cancel and then open a nested confirmation, the second one overwrites the first.

Two approaches work. Either encode the stack (?dialog=cancel.confirm) or use distinct parameter names (?dialog=cancel&confirm=1). The stack encoding is cleaner because history behaves correctly by default — hitting back pops one layer, not the whole stack.

If your product genuinely needs three levels of nested modals, though, stop and redesign. That's a flow, not a dialog.

Accessibility Falls Out Almost For Free

The WAI-ARIA authoring practices for dialogs give you the checklist: focus trap, initial focus, return focus on close, aria-labelledby, aria-modal, and Escape handling. Every serious headless UI library (Radix, Ark, React Aria, Headless UI) ships this correctly.

What route-based modals add is predictability. Because opening and closing go through the same code path, focus restoration works even when the close was triggered by a browser back button rather than an in-app click. That was the Safari iOS bug we mentioned earlier — the boolean version had no idea the dialog had closed because history changed underneath it, so focus was never restored.

One caveat: if the modal is triggered by navigation from a completely different page (deep link, email, push notification), there is no previous focus target to return to. Set an initial focus explicitly — usually the primary action or the first form field — and make sure your close button routes to a sensible parent, not history.back().

What Design Needs To Own

This pattern only pays off if design and engineering agree on a few things up front. In our design reviews we now ask:

  • What is this dialog's URL? If nobody can answer, it's a hint the flow isn't well-defined.
  • What page is behind it? Because refresh will land there if the modal fails to hydrate.
  • What happens on deep link? Specifically, if a user arrives from an email straight into ?dialog=refund, does the page behind it make sense?
  • What's the close target? For nested cases, does close go back one layer or all the way out?

Those four questions catch about 80% of the flow problems we used to only discover during QA.

Motion And Perceived Weight

Route-driven modals have one motion pitfall: because open/close is a navigation, the animation can feel sluggish if you await routing before animating. Do both at once. Optimistically render the dialog in its entering state as soon as the user clicks, and let the URL update in parallel. On close, animate out first, then update the URL when the animation resolves — otherwise the exit gets clipped.

Keep dialog transitions short (in the 150–250ms range works well for us) and use the same easing curve as the rest of your system. A dialog that opens with a different curve than your dropdowns reads as a bug even when nothing is technically wrong.

Analytics, Testing, And The Boring Wins

Once dialogs are URLs, three things get much cheaper.

Analytics becomes pageview tracking. You no longer sprinkle track('modal_opened', ...) calls; your existing route instrumentation covers it. Funnels become readable because every step has a URL.

End-to-end tests get shorter. Instead of clicking through five buttons to reach a dialog state, the test navigates directly to the URL. This is enormous for flaky-test reduction — most modal flakiness comes from waiting on transitions before the trigger click.

Support gets a superpower. "Send me the URL from your address bar" now reproduces the exact state the customer is looking at, including which modal is open and on what.

Where We'd Start

If you're staring at an existing codebase full of useState(false) modals, don't rewrite everything on Monday morning. Pick the modals that matter most — the ones tied to conversion, the ones support keeps mentioning, the ones with forms that lose data on reload — and route just those first. A few concrete moves:

  1. Audit which dialogs users could reasonably deep-link to. Route those.
  2. Standardise on one query parameter name across the app (dialog, sheet, whatever) so devs stop bikeshedding.
  3. Wrap it in a hook so component code never touches the router directly.
  4. Add a lint rule or code review checklist item: new dialogs must justify why they aren't routed.

The payoff isn't glamorous. Nobody will notice that back works. Nobody will thank you for shareable URLs. But your bug tracker gets quieter, your funnels get honest, and your accessibility audit stops flagging the same three modals every quarter. That's the trade we'll take.

#UX Patterns#Accessibility#Frontend Architecture#Design Systems

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project