# The Modal Is the Last Resort

**NEVER** · **ID:** `interactions-impeccable-modal-last-resort` · **Category:** interactions
**Source:** [impeccable](https://impeccable.style/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-impeccable-modal-last-resort

> NEVER: Reach for a modal as the first thought. A modal steals focus, blocks the page, and destroys the context the user was acting on — they must now hold the row they clicked in working memory. Exhaust the cheaper alternatives that keep context on screen: edit in place, use a side panel or a dedicated route, and replace a confirmation dialog with an undo toast wherever the action is reversible (compose with `interactions-confirm-destructive`). What survives is the narrow case a modal is actually for: one action, destructive, and irreversible — nothing to undo it with.

Exhaust inline editing, side panels, and dedicated routes before you reach for a modal

impeccable ships this under "Absolute bans" — match-and-refuse, alongside gradient text and glassmorphism. The reason it is banned rather than merely discouraged is that a modal is expensive in a way that is invisible to the person adding it: it steals focus, blocks the page behind it, and destroys the context you were acting on, so the user must now hold the row they clicked in working memory while they read the dialog. Stack two and the original context is gone entirely. Nearly every modal has a cheaper alternative that keeps the context on screen: rename edits in place, a destructive-but-reversible action wants an undo toast rather than a confirmation, and supporting information belongs in a side panel or on its own route. Cross-reference `interactions-confirm-destructive`, which is about WHETHER to confirm at all — this principle is about modal OVERUSE, and the two answers compose: most deletes should be undoable rather than confirmed, which removes the modal entirely. What survives is the narrow case where a modal is genuinely right: one action, destructive, and irreversible — nothing to undo it with.

## Bad — do not do this

`interactions-impeccable-modal-last-resort-bad`

```tsx
import { useState } from 'react';

type Layer = 'rename' | 'confirm' | 'details';

/**
 * Bad: the modal is the first thought for every interaction.
 *
 * Renaming a project, confirming a delete, and viewing details each throw a
 * modal. Open two and they stack — each one steals focus, blocks the page, and
 * hides the very row you were acting on, so you lose the context you needed to
 * decide.
 */
export function ImpeccableModalLastResortBad() {
  const [stack, setStack] = useState<Layer[]>([]);

  const open = (layer: Layer) => setStack((s) => [...s, layer]);
  const popTop = () => setStack((s) => s.slice(0, -1));

  const titles: Record<Layer, string> = {
    rename: 'Rename project',
    confirm: 'Delete project?',
    details: 'Project details',
  };

  return (
    <div className="relative w-full overflow-hidden rounded-lg border border-border bg-card">
      {/* The page underneath */}
      <div className="space-y-3 p-4">
        <h4 className="text-sm font-semibold text-foreground">Projects</h4>
        <div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
          <span className="text-sm text-foreground">acme-api</span>
          <div className="flex gap-2">
            <button
              onClick={() => open('rename')}
              className="rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Rename
            </button>
            <button
              onClick={() => open('details')}
              className="rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Details
            </button>
            <button
              onClick={() => open('confirm')}
              className="rounded-md border border-border px-2 py-1 text-xs text-error focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Delete
            </button>
          </div>
        </div>
        <p className="text-xs text-error">
          Every one of these three opens a modal. Open two &mdash; they stack, and the row you were
          working on is gone.
        </p>
      </div>

      {/* Stacked modals, each blocking the one below */}
      {stack.length > 0 && (
        <div className="absolute inset-0 bg-overlay">
          {stack.map((layer, i) => (
            <div
              key={`${layer}-${i}`}
              className="absolute left-1/2 w-64 -translate-x-1/2 rounded-lg border border-border bg-card p-4 shadow-lg"
              style={{ top: `${12 + i * 18}px` }}
            >
              <p className="text-sm font-semibold text-foreground">{titles[layer]}</p>
              <p className="mt-1 text-xs text-muted-foreground">
                Layer {i + 1} of {stack.length}. The project row is behind this.
              </p>
              <button
                onClick={popTop}
                className="mt-3 rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Close
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

## Good — do this

`interactions-impeccable-modal-last-resort-good`

```tsx
import { useRef, useState } from 'react';

/**
 * Good: the inline / progressive alternatives are exhausted first.
 *
 * - Rename → edits in place. The row never moves, focus never leaves it.
 * - Delete → optimistic removal plus an undo toast. Cheap to reverse, so it does
 *   not deserve a confirmation at all.
 * - Details → a side panel. The list stays visible next to it.
 * - The one modal left is reserved for the genuinely destructive, irreversible
 *   action: deleting the whole workspace.
 */
export function ImpeccableModalLastResortGood() {
  const [name, setName] = useState('acme-api');
  const [editing, setEditing] = useState(false);
  const [deleted, setDeleted] = useState(false);
  const [panelOpen, setPanelOpen] = useState(false);
  const [confirmOpen, setConfirmOpen] = useState(false);
  const deleteBtnRef = useRef<HTMLButtonElement>(null);

  return (
    <div className="w-full space-y-3">
      <div className="rounded-lg border border-border bg-card p-4">
        <h4 className="mb-3 text-sm font-semibold text-foreground">Projects</h4>

        {deleted ? (
          <div className="flex items-center justify-between rounded-md border border-border bg-muted px-3 py-2">
            <span className="text-xs text-muted-foreground">Deleted &ldquo;{name}&rdquo;</span>
            <button
              onClick={() => setDeleted(false)}
              className="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              Undo
            </button>
          </div>
        ) : (
          <div className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2">
            {editing ? (
              // Rename: inline, in the row itself
              <input
                autoFocus
                value={name}
                aria-label="Project name"
                onChange={(e) => setName(e.target.value)}
                onBlur={() => setEditing(false)}
                onKeyDown={(e) => e.key === 'Enter' && setEditing(false)}
                className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              />
            ) : (
              <span className="min-w-0 flex-1 truncate text-sm text-foreground">{name}</span>
            )}
            <div className="flex shrink-0 gap-2">
              <button
                onClick={() => setEditing(true)}
                className="rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Rename
              </button>
              <button
                onClick={() => setPanelOpen((v) => !v)}
                aria-expanded={panelOpen}
                className="rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Details
              </button>
              <button
                onClick={() => setDeleted(true)}
                className="rounded-md border border-border px-2 py-1 text-xs text-error focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Delete
              </button>
            </div>
          </div>
        )}

        {/* Details: a side panel, the list stays visible */}
        {panelOpen && (
          <aside className="mt-3 rounded-md border border-border bg-muted p-3">
            <p className="text-xs font-semibold text-foreground">Details</p>
            <p className="mt-1 text-xs text-muted-foreground">
              Region eu-central-1 &middot; 3 deployments &middot; created Mar 2026
            </p>
          </aside>
        )}
      </div>

      {/* The one action that earns a modal: destructive and irreversible */}
      <div className="rounded-lg border border-error bg-card p-4">
        <p className="text-xs font-semibold text-foreground">Danger zone</p>
        <button
          ref={deleteBtnRef}
          onClick={() => setConfirmOpen(true)}
          className="mt-2 rounded-md border border-error px-2 py-1 text-xs text-error focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Delete workspace permanently
        </button>

        {confirmOpen && (
          <div
            role="alertdialog"
            aria-modal="true"
            aria-labelledby="ws-delete-title"
            className="mt-3 rounded-md border border-error bg-muted p-3"
          >
            <p id="ws-delete-title" className="text-xs font-semibold text-foreground">
              Delete workspace and all 14 projects?
            </p>
            <p className="mt-1 text-xs text-muted-foreground">
              This cannot be undone. There is nothing to undo it with.
            </p>
            <div className="mt-2 flex gap-2">
              <button
                autoFocus
                onClick={() => {
                  setConfirmOpen(false);
                  deleteBtnRef.current?.focus();
                }}
                className="rounded-md border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Cancel
              </button>
              <button
                onClick={() => {
                  setConfirmOpen(false);
                  deleteBtnRef.current?.focus();
                }}
                className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                Delete workspace
              </button>
            </div>
          </div>
        )}
      </div>

      <p className="text-xs text-success">
        Three interactions, zero modals: rename edits in place, delete is undoable, details open
        beside the list. The modal is spent on the one action that cannot be taken back.
      </p>
    </div>
  );
}
```

## References

- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [W3C — ARIA APG, Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
