# Escape Closes the Topmost Layer

**MUST** · **ID:** `interactions-escape-dismiss` · **Category:** interactions
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-escape-dismiss

> MUST: Escape closes dialogs and overlays, and closes exactly ONE layer — the topmost open one. A nested popover and its parent dialog both listening for Escape on `window` means one keypress closes both and the user loses the form they were filling; the open layer handles the key and calls `stopPropagation()`. Native `<dialog>` and Radix-style primitives keep that stack for you. (Dismissal, not focus — `interactions-manage-focus` covers trap/return.)

Escape must dismiss a dialog or overlay, and it must dismiss exactly one layer — the one on top

Escape is the universal "get me out of here" key, and a dialog that only closes via an X button strands anyone who is not holding a mouse — a keyboard user has to Tab around a trapped focus ring hunting for the exit. But the interesting half of this rule is the nested case, which almost every hand-rolled implementation gets wrong. Put a select or a popover inside a dialog, attach `keydown` for Escape on `window` in both, and one keypress closes both: the child dismisses, and the same event keeps bubbling to the dialog's listener. The user tried to close a dropdown and lost their whole form. The fix is layer discipline — the topmost open layer handles Escape and calls `stopPropagation()` so it does not reach anything beneath it. Native `<dialog>` and Radix-style primitives maintain that stack for you, which is why the a11y skill closes with "for complex widgets (menu, dialog, combobox), prefer established accessible primitives over custom behavior". This is distinct from interactions-manage-focus, which covers trapping focus and returning it — dismissal is a separate obligation.

## Rule snippet

```tsx
function onKeyDown(e: React.KeyboardEvent) {
  if (e.key !== "Escape") return;
  e.stopPropagation(); // do not let the parent layer close too
  close();
}
```

## Bad — do not do this

`interactions-escape-dismiss-bad`

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

/**
 * BAD: two ways to get Escape wrong, both extremely common.
 * 1. The plain dialog has an X button and nothing else — Escape does nothing at all.
 * 2. The nested dialog and its popover each listen on `window` and neither stops
 *    propagation, so one Escape keypress dismisses BOTH layers. The user tried to
 *    close a dropdown and lost the whole dialog.
 */
export function EscapeDismissBad() {
  const [plainOpen, setPlainOpen] = useState(false);
  const [nestedOpen, setNestedOpen] = useState(false);
  const [popoverOpen, setPopoverOpen] = useState(false);

  // The dialog's own Escape listener, on window.
  useEffect(() => {
    if (!nestedOpen) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setNestedOpen(false);
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [nestedOpen]);

  // The popover's Escape listener, also on window. Nothing coordinates the two.
  useEffect(() => {
    if (!popoverOpen) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setPopoverOpen(false);
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [popoverOpen]);

  const button =
    'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring';

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap gap-2">
        <button type="button" className={button} onClick={() => setPlainOpen(true)}>
          Open dialog (X only)
        </button>
        <button type="button" className={button} onClick={() => setNestedOpen(true)}>
          Open dialog with popover
        </button>
      </div>

      {plainOpen && (
        <div className="rounded-lg border border-border bg-card p-4">
          <div className="flex items-start justify-between gap-4">
            <p className="text-sm text-foreground">
              Press Escape. Nothing happens — the only way out is the X.
            </p>
            <button
              type="button"
              aria-label="Close dialog"
              className={button}
              onClick={() => setPlainOpen(false)}
            >
              X
            </button>
          </div>
        </div>
      )}

      {nestedOpen && (
        <div className="space-y-3 rounded-lg border border-border bg-card p-4">
          <p className="text-sm text-foreground">Open the popover, then press Escape once.</p>
          <button type="button" className={button} onClick={() => setPopoverOpen((v) => !v)}>
            Choose a plan
          </button>
          {popoverOpen && (
            <ul className="rounded-md border border-border bg-popover p-1">
              {['Hobby', 'Pro', 'Enterprise'].map((plan) => (
                <li key={plan}>
                  <button
                    type="button"
                    className="w-full rounded-sm px-2 py-1 text-left text-sm text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    {plan}
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}

      <p className="text-xs text-destructive">
        One keypress, two layers dismissed: both listeners are on `window`, so the same Escape event closes
        the popover and then the dialog underneath it.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-escape-dismiss-good`

```tsx
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';

/**
 * GOOD: Escape closes exactly one layer — the topmost one — and closing the dialog
 * returns focus to the trigger that opened it.
 * The popover handles Escape first and calls stopPropagation(), so the event never
 * reaches the dialog beneath it.
 */
export function EscapeDismissGood() {
  const [dialogOpen, setDialogOpen] = useState(false);
  const [popoverOpen, setPopoverOpen] = useState(false);
  const [plan, setPlan] = useState('Hobby');
  const triggerRef = useRef<HTMLButtonElement>(null);
  const dialogRef = useRef<HTMLDivElement>(null);

  // Initial focus inside the dialog when it opens.
  useEffect(() => {
    if (dialogOpen) dialogRef.current?.focus();
  }, [dialogOpen]);

  const closeDialog = () => {
    setPopoverOpen(false);
    setDialogOpen(false);
    triggerRef.current?.focus(); // restore focus to the trigger
  };

  // The dialog only sees Escape if no layer above it claimed the event.
  const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Escape') closeDialog();
  };

  const onPopoverKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key !== 'Escape') return;
    e.stopPropagation(); // topmost layer wins: the dialog never hears this
    setPopoverOpen(false);
  };

  const button =
    'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring';

  return (
    <div className="space-y-3">
      <button ref={triggerRef} type="button" className={button} onClick={() => setDialogOpen(true)}>
        Open dialog
      </button>

      {dialogOpen && (
        <div
          ref={dialogRef}
          role="dialog"
          aria-modal="true"
          aria-label="Plan settings"
          tabIndex={-1}
          onKeyDown={onDialogKeyDown}
          className="space-y-3 rounded-lg border border-border bg-card p-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          <p className="text-sm text-foreground">
            Selected plan: <span className="font-medium">{plan}</span>
          </p>

          <div onKeyDown={onPopoverKeyDown}>
            <button
              type="button"
              className={button}
              aria-expanded={popoverOpen}
              onClick={() => setPopoverOpen((v) => !v)}
            >
              Choose a plan
            </button>
            {popoverOpen && (
              <ul className="mt-1 rounded-md border border-border bg-popover p-1">
                {['Hobby', 'Pro', 'Enterprise'].map((option) => (
                  <li key={option}>
                    <button
                      type="button"
                      onClick={() => {
                        setPlan(option);
                        setPopoverOpen(false);
                      }}
                      className="w-full rounded-sm px-2 py-1 text-left text-sm text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      {option}
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </div>

          <button type="button" className={button} onClick={closeDialog}>
            Done
          </button>
        </div>
      )}

      <p className="text-xs text-success">
        First Escape closes the popover only. A second Escape closes the dialog and puts focus back on the
        button you opened it with. One keypress, one layer.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [W3C — ARIA APG, Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
