# Modals Dim and Recede

**SHOULD** · **ID:** `interactions-modal-dims-and-recedes` · **Category:** interactions
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-modal-dims-and-recedes

> SHOULD: For a blocking dialog, dim the page with a scrim and push the background back slightly so depth signals "paused" — and pair it with the semantics (trap focus, role="dialog", aria-modal). For a parallel, non-blocking panel (inspector, side sheet), do NOT dim the page; it should coexist with the content.

A blocking dialog dims the page and pushes it back to establish focus; a non-blocking panel coexists without a scrim

Modality is a promise about attention, and depth is how you keep it. When a task truly blocks — a destructive confirm, a required choice — dim the page behind a scrim and push it back a touch; the recede plus the dim say "everything else is paused" before the user reads a word. Skip that and the dialog reads as just one more floating panel competing with a still-bright background. The inverse mistake is as common: a parallel, non-blocking surface (an inspector, a side sheet you keep working alongside) that dims the whole page claims focus it does not need and makes the app feel modal when it is not. This is a different axis from "The Modal Is the Last Resort", which argues about *whether* to interrupt at all; this is about doing the interruption legibly *once you have decided to*. Pair the visual treatment with the semantics — a blocking dialog also traps focus and is labelled as a dialog (see the focus-management principles) — so the depth cue and the accessibility contract agree.

## Rule snippet

```tsx
<div class="fixed inset-0 bg-black/50" />
<main style="transform: scale(0.96)">…</main>
```

## Bad — do not do this

`interactions-modal-dims-and-recedes-bad`

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

export function ModalDimsAndRecedesBad() {
  // Open by default so the (missing) focus treatment is visible at rest.
  const [open, setOpen] = useState(true);
  return (
    <div className="w-full max-w-sm">
      <div className="relative overflow-hidden rounded-xl border border-border" style={{ height: 260 }}>
        {/* Background stays full-size and fully lit — no scrim, no recede. */}
        <div className="p-4">
          <div className="h-5 w-24 rounded bg-muted" />
          <div className="mt-3 space-y-2">
            <div className="h-3 w-full rounded bg-muted" />
            <div className="h-3 w-5/6 rounded bg-muted" />
            <div className="h-3 w-2/3 rounded bg-muted" />
          </div>
          <button
            onClick={() => setOpen(true)}
            className="mt-4 rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Open dialog
          </button>
        </div>

        {/* Dialog just pops in on top with no scrim: it reads as one more panel, not a blocking task. */}
        <div
          className={`absolute inset-x-4 bottom-4 rounded-lg border border-border bg-card p-4 transition-all duration-300 ${
            open ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
          }`}
        >
          <p className="text-sm font-medium text-foreground">Delete file?</p>
          <p className="mt-1 text-xs text-muted-foreground">This can’t be undone.</p>
          <div className="mt-3 flex justify-end gap-2">
            <button
              onClick={() => setOpen(false)}
              className="rounded px-2 py-1 text-xs text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Cancel
            </button>
            <button
              onClick={() => setOpen(false)}
              className="rounded bg-destructive px-2 py-1 text-xs text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Delete
            </button>
          </div>
        </div>
      </div>
      <p className="mt-4 text-xs text-destructive">
        No scrim, background unchanged: nothing signals the page is paused, so the dialog reads as just another panel.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-modal-dims-and-recedes-good`

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

export function ModalDimsAndRecedesGood() {
  // Open by default so the scrim + recede is visible at rest, not only after a click.
  const [open, setOpen] = useState(true);
  return (
    <div className="w-full max-w-sm">
      <div className="relative overflow-hidden rounded-xl border border-border" style={{ height: 260 }}>
        {/* A blocking task pushes the page back a touch — depth says "this is paused". */}
        <div
          className="p-4 transition-transform duration-300"
          style={{ transform: open ? 'scale(0.94)' : 'scale(1)', transformOrigin: 'center' }}
        >
          <div className="h-5 w-24 rounded bg-muted" />
          <div className="mt-3 space-y-2">
            <div className="h-3 w-full rounded bg-muted" />
            <div className="h-3 w-5/6 rounded bg-muted" />
            <div className="h-3 w-2/3 rounded bg-muted" />
          </div>
          <button
            onClick={() => setOpen(true)}
            className="mt-4 rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Open dialog
          </button>
        </div>

        {/* Dimming scrim: the background is present but demoted. Click it to dismiss. */}
        <div
          className={`absolute inset-0 bg-black/50 transition-opacity duration-300 ${
            open ? 'opacity-100' : 'pointer-events-none opacity-0'
          }`}
          onClick={() => setOpen(false)}
        />

        <div
          className={`absolute inset-x-4 bottom-4 rounded-lg bg-card p-4 shadow-xl transition-all duration-300 ${
            open ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
          }`}
        >
          <p className="text-sm font-medium text-foreground">Delete file?</p>
          <p className="mt-1 text-xs text-muted-foreground">This can’t be undone.</p>
          <div className="mt-3 flex justify-end gap-2">
            <button
              onClick={() => setOpen(false)}
              className="rounded px-2 py-1 text-xs text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Cancel
            </button>
            <button
              onClick={() => setOpen(false)}
              className="rounded bg-destructive px-2 py-1 text-xs text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Delete
            </button>
          </div>
        </div>
      </div>
      <p className="mt-4 text-xs text-success">
        Scrim dims the page and the background recedes: the depth cue makes it obvious the task is blocking.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — apple-design skill](https://github.com/emilkowalski/skills/tree/main/skills/apple-design)
- [Apple HIG — Modality](https://developer.apple.com/design/human-interface-guidelines/modality)
