All articles
Design & UXMay 29, 2026 6 min read

The Hidden Cost of Modal Dialogs: A Refactor Story

Modals feel like a free UI primitive until you count the focus traps, the scroll locks, the stacking bugs, and the conversion drop. Here's how we cut a client's modal count in half and got measurable wins.

The Hidden Cost of Modal Dialogs: A Refactor Story

Modals are the cockroach of UI patterns. They survive every redesign, every accessibility audit, every quarterly UX review — and they keep multiplying. We recently took over a SaaS dashboard with 41 distinct modal dialogs. By the end of the refactor it had 18, and the support ticket volume for "I can't find the save button" dropped to roughly a third of what it was.

This is the story of that refactor, the heuristics we now use to decide when a modal is the right answer, and the patterns we reach for when it isn't.

Why teams reach for modals (and why it's usually wrong)

A modal is the path of least resistance for a designer who needs to show something without redesigning the page, and for a developer who needs to mount a component without thinking about layout. That's the entire appeal. It's not that modals are inherently bad — it's that they're chosen by default rather than by fit.

The actual cost shows up later:

  • Focus management has to be implemented correctly, every time, including return-focus on close.
  • Scroll lock behaves differently on iOS Safari, Android Chrome, and desktop, and a half-broken implementation causes background scrolling or layout shift.
  • Stacking breaks the moment a second modal opens from inside the first — and somebody always builds that.
  • Deep links stop working. You can't share a URL to "the edit-billing modal" without custom routing logic.
  • Mobile reality: a 600px-tall modal on a 720px viewport with a soft keyboard is a usability disaster.

In our experience, every modal added to a product carries a 2–4 hour ongoing maintenance tax per quarter once you factor in the bug reports, the accessibility regressions, and the inevitable "why does the form reset when I scroll" tickets.

The audit: how we decided what stays

Before deleting anything, we categorised every modal in the product into four buckets. This is the framework we now use on every engagement.

Bucket 1: Genuine interrupts

These are decisions the user must make right now, where continuing the underlying task would be incorrect or destructive. Confirming a permanent delete. Re-authenticating before a sensitive action. A payment 3DS challenge. These stay as modals — specifically, as proper <dialog> elements with the modal behaviour, because the semantics match the intent.

Bucket 2: Side quests

These are tasks adjacent to the main flow but not blocking it. Editing a profile field, adding a tag, picking a date range. About 60% of the modals we found lived here, and almost none of them needed to be modal. These became inline editors, popovers, or side sheets.

Bucket 3: Forms that grew up

Multi-step forms that started as "just a quick modal" and became 8-field, 3-tab beasts. These are the worst offenders. They need their own route, full stop. If a form has validation that needs scrolling, it is no longer a dialog.

Bucket 4: Notifications pretending to be modals

"Your export is ready." "New feature available." These should be toasts, banners, or inbox items — never a thing that steals focus from whatever the user was doing.

The replacement patterns

Deleting a modal is the easy part. Picking what replaces it is where most refactors go wrong. Here's how we map each bucket.

Inline editing for side quests

For field-level edits, an inline editor keeps context. The user sees the value, clicks it, edits in place, commits. No focus jump, no scroll lock, no return-focus bug.

function EditableField({ value, onSave, label }: Props) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  if (!editing) {
    return (
      <button
        onClick={() => setEditing(true)}
        className="group flex items-baseline gap-2 text-left"
        aria-label={`Edit ${label}`}
      >
        <span>{value}</span>
        <PencilIcon className="opacity-0 group-hover:opacity-60" />
      </button>
    );
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSave(draft);
        setEditing(false);
      }}
    >
      <input
        autoFocus
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={() => setEditing(false)}
        aria-label={label}
      />
    </form>
  );
}

The accessibility win here is huge: screen reader users hear the label, the current value, and a clear "edit" affordance, instead of "button, button, button" followed by a dialog announcement.

The native dialog element for real interrupts

If you're still hand-rolling modals with <div role="dialog">, stop. The HTML <dialog> element with showModal() gives you the inert background, the top-layer rendering, the Escape-to-close behaviour, and the focus trap for free across every modern browser.

function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
  const ref = useRef<HTMLDialogElement>(null);

  return (
    <>
      <button onClick={() => ref.current?.showModal()}>Delete</button>
      <dialog ref={ref} className="backdrop:bg-black/40 rounded-lg p-6">
        <h2>Delete this project?</h2>
        <p>This cannot be undone.</p>
        <form method="dialog" className="mt-4 flex gap-2">
          <button value="cancel">Cancel</button>
          <button value="confirm" onClick={onConfirm} className="text-red-600">
            Delete permanently
          </button>
        </form>
      </dialog>
    </>
  );
}

The method="dialog" form trick is underused — it closes the dialog and returns the button's value without any extra JavaScript. Combined with the ::backdrop pseudo-element for styling, you get a working accessible modal in under 30 lines.

Side sheets for context-heavy tasks

When a side quest needs more than a single field — say, editing a customer record with five inputs — a side sheet beats a modal. It preserves the page underneath as visible context, slides in from the edge, and on mobile it can take over the full screen without the awkward "dialog inside a viewport" feeling.

We treat side sheets as non-modal by default: clicking outside closes them, the page remains scrollable, and Escape still works. Only escalate to modal behaviour if there's unsaved destructive risk.

Popovers for transient choices

The Popover API is finally stable enough to use without a polyfill in 2026. For date pickers, action menus, filter selectors — anything that's a temporary surface attached to a trigger — popover="auto" and popovertarget give you anchoring, light-dismiss, and top-layer rendering with zero JS.

What actually moved the numbers

After the refactor, the metrics that improved most were not the ones we expected.

  • Time to complete profile edits dropped meaningfully because inline editing removed two clicks (open modal, close modal) per field.
  • Mobile form abandonment on the billing flow improved once we moved it from a modal to a dedicated route. The soft keyboard no longer covered the submit button.
  • Accessibility audit findings went from 23 dialog-related issues to 4. Most of the remaining ones are in third-party embeds.
  • Bundle size dropped by around 18KB gzipped because we removed our custom focus-trap library and a portal wrapper that existed only to manage z-index for nested modals.

The surprise was support ticket categorisation. Tickets tagged "can't find" or "button missing" dropped sharply. It turned out a lot of users were dismissing modals to read the page underneath, then forgetting how to get the modal back.

The decision tree we use now

Before any new modal goes into a product we build, the designer and engineer answer four questions together:

  1. Will continuing the underlying task produce a wrong result? If no, it's not a modal.
  2. Does the user need to see page context while doing this? If yes, use a side sheet or inline editor.
  3. Is this more than three fields or any kind of multi-step? If yes, give it a route.
  4. Is this just telling the user something? If yes, it's a toast or banner.

Only if the answer to question one is "yes, the task is genuinely blocking" does a modal earn its place. And even then, it's a <dialog> element, not a div with ARIA glued on.

Where we'd start

If you're staring at a product full of modals and not sure where to begin: count them. Just count. Open every page, every state, and make a list. Most teams are shocked by the number. Then sort by which ones open from inside other modals — kill those first, because they're the source of the worst bugs. Replace the destructive-confirm ones with native <dialog>, move the multi-step forms to routes, and convert the rest to inline editors or side sheets over the next sprint or two.

We've run this play on three products in the last year. The pattern holds: roughly half the modals shouldn't exist, a quarter should be a different surface, and the rest just need to be implemented properly. If you'd like a hand running the audit, that's the kind of work our product engineering team does most weeks.

#UX#Accessibility#Design Patterns#Frontend

Want a team like ours?

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

Start a project