core accessibility principles for modern frameworks

Escape Key & Click-Outside Patterns for Overlays

Every overlay needs a way out, and there are four of them: the close button, the Escape key, a click outside, and — for non-modal overlays — tabbing past the end. Each is a separate code path, each must restore focus to the same place, and the two that involve global event listeners are where the bugs live. Escape handlers stack up and close three layers at once; click-outside handlers fire on scrollbar drags and on elements that were removed mid-click.

This guide covers the dismissal half of the overlay contract, building on Keyboard Navigation Patterns for Modals.

WCAG Success Criteria Addressed:

  • 2.1.1 Keyboard
  • 2.1.2 No Keyboard Trap
  • 2.4.3 Focus Order
  • 1.4.13 Content on Hover or Focus
Four ways out of an overlay, all restoring focus to the trigger An open dialog with four dismissal paths drawn as arrows: the close button, the Escape key, a click on the backdrop outside the panel, and — for non-modal overlays only — tabbing past the last control. All four converge on a single close routine that restores focus to the trigger that opened the overlay. A note states that a dismissal path which skips that routine leaves focus on a removed node. Close button Escape key Click outside Tab past the end non-modal only One close routine set state closed remove listeners restore focus Trigger refocused the user keeps their place Any path that closes the overlay without going through the close routine leaves focus on a node that no longer exists.

Prerequisites

  • An overlay component that already manages open state and, if modal, a focus trap.
  • A ref to the element that opened it, so focus can be restored — see fixing focus trap issues in React portals.
  • A clear answer to whether the overlay is modal. Modal overlays must close on Escape; non-modal ones should also close when focus leaves.

One Escape Handler, on the Overlay

The reflex is document.addEventListener('keydown'). With two overlays open — a dialog containing a select — both handlers fire and both layers close on one press.

Implementation Guidelines:

  • Attach the handler to the overlay element itself, not to document. Focus is inside the overlay, so the event bubbles through it anyway.
  • Call event.stopPropagation() when you handle Escape, so an outer layer does not also close.
  • Handle keydown, not keyup — the browser's own dialog dismissal and most component libraries use keydown, and mixing them causes ordering surprises.
  • If you must use a document-level handler, close only the topmost layer by consulting a small stack of open overlays.
'use client';
export function Overlay({ onClose, children }: OverlayProps) {
  return (
    <div
      role="dialog"
      aria-modal="true"
      onKeyDown={(event) => {
        if (event.key !== 'Escape') return;
        // A11y rationale: 2.1.1 Keyboard — Escape dismisses exactly one layer,
        // so a select inside a dialog does not take the dialog with it.
        event.stopPropagation();
        onClose();
      }}
    >
      {children}
    </div>
  );
}

Native <dialog> handles this for you: it fires a cancel event on Escape and closes the top-most dialog only. If the design allows it, that is less code and better default behaviour.

Click-Outside Without the Classic Bugs

Click-outside is a convenience, never the only way out. It also has three failure modes that are easy to hit and hard to notice.

Three click-outside bugs and their fixes Three rows. Listening on mousedown alone closes the overlay when a text selection starts inside it and ends outside; the fix is to require both the down and up events to land outside. Listening on the document closes the overlay when the user drags the scrollbar; the fix is to ignore events whose coordinates fall outside the document element's client width. Testing the event target against the overlay fails when the target was removed during the click; the fix is to capture the path at the start of the interaction. Text selection that ends outside Dragging a selection from inside to outside fires a click outside and closes the panel. Fix: require mousedown and mouseup to both land outside. Scrollbar drags The scrollbar is outside the overlay, so grabbing it dismisses the overlay mid-scroll. Fix: ignore events whose clientX exceeds documentElement.clientWidth. Targets removed during the click A button that unmounts on mousedown is no longer inside the overlay when the click resolves. Fix: decide from the mousedown target, captured before any re-render.

Implementation Guidelines:

  • Require both pointerdown and pointerup to land outside before closing, which kills the text-selection bug.
  • Ignore pointer events beyond document.documentElement.clientWidth/clientHeight, which is where scrollbars live.
  • Decide from the pointerdown target, captured before React can re-render anything away.
  • Never make click-outside the only dismissal. Pointer-only exits are unreachable by keyboard, which is a 2.1.1 failure.
useEffect(() => {
  if (!open) return;
  let startedInside = false;

  const onDown = (event: PointerEvent) => {
    // Scrollbar drags report coordinates outside the client box — ignore them.
    if (event.clientX > document.documentElement.clientWidth) return;
    startedInside = Boolean(panelRef.current?.contains(event.target as Node));
  };

  const onUp = (event: PointerEvent) => {
    const endedInside = Boolean(panelRef.current?.contains(event.target as Node));
    if (!startedInside && !endedInside) onClose();
  };

  document.addEventListener('pointerdown', onDown, true);
  document.addEventListener('pointerup', onUp, true);
  return () => {
    document.removeEventListener('pointerdown', onDown, true);
    document.removeEventListener('pointerup', onUp, true);
  };
}, [open, onClose]);

Every Path Restores Focus

Four dismissal paths, one restoration. The reliable way to guarantee that is to make focus restoration a consequence of closing, not of any particular handler.

Implementation Guidelines:

  • Restore focus in a cleanup effect keyed on the open state, so it runs no matter which path caused the close.
  • Store the trigger element when the overlay opens (document.activeElement at that moment), rather than passing a ref through every call site.
  • Guard against a trigger that has since been removed: fall back to a stable container rather than letting focus fall to <body>.
  • Restore focus after the overlay unmounts, or the trap may pull it straight back in.
useEffect(() => {
  if (!open) return;
  const trigger = document.activeElement as HTMLElement | null;

  return () => {
    // A11y rationale: 2.4.3 Focus Order — one restoration point covers Escape,
    // the close button, click-outside and programmatic closes alike.
    if (trigger?.isConnected) trigger.focus();
    else containerRef.current?.focus();
  };
}, [open]);

Non-Modal Overlays Also Close on Focus Leaving

Menus, popovers and comboboxes are not modal: Tab should move out of them naturally, and they should close when it does.

Implementation Guidelines:

  • Listen for focusout on the wrapper and close when event.relatedTarget is outside it.
  • Do not close on plain blur; moving focus from the trigger into the panel fires blur and would close the panel immediately.
  • Keep Escape working as well — some users reach for it before Tab, and 1.4.13 requires a dismissal for anything shown on hover or focus.
  • Never trap focus in a non-modal overlay. Trapping in a menu is a 2.1.2 No Keyboard Trap failure.
Why a non-modal panel must close on focusout, not on blur Two focus movements from a menu trigger. Moving focus from the trigger into the panel fires a blur event on the trigger, so a blur-based handler closes the panel the instant the user tries to enter it. A focusout handler on the wrapper checks the related target: because it is still inside the wrapper, the panel stays open. When focus moves to a control outside the wrapper, the same handler closes the panel. Closing on blur Trigger loses focus → handler fires Panel closes before focus arrives Keyboard users can never enter it ✗ unreachable by keyboard Closing on focusout, with a check relatedTarget inside → stay open relatedTarget outside → close Tab still leaves freely — no trap ✓ enterable and dismissible

How to Verify

  • Layered Escape test. Open a dialog, open a select inside it, press Escape. Only the select should close; a second press closes the dialog.
  • Selection drag test. Select text inside the overlay and release the mouse outside it. The overlay must stay open.
  • Scrollbar test. With the page scrollable behind a non-modal popover, drag the scrollbar. The popover must not close.
  • Four-path focus test. Close via each path in turn and confirm focus returns to the trigger every time, with a visible ring.
  • Keyboard-only test. With no pointer, confirm the overlay can be dismissed. If the only exit is click-outside, it fails 2.1.1 Keyboard.

Common Accessibility Mistakes

  • A document-level Escape listener per overlay. One press closes every open layer.
  • Click-outside on click alone. Text selections and scrollbar drags dismiss the overlay unexpectedly.
  • Restoring focus inside the close button's handler only. Escape and click-outside then leave focus on a removed node.
  • Trapping focus in a non-modal menu. Users cannot Tab out, which is a keyboard trap.
  • Closing a non-modal panel on blur. Focus moving into the panel closes it, making it unreachable by keyboard.
  • No close button. Escape and click-outside are not discoverable; a visible, named close control is the baseline.

Conclusion

Dismissal looks trivial and generates a disproportionate share of overlay bugs, because it is four code paths pretending to be one. Put the Escape handler on the overlay and stop propagation so layers close one at a time. Build click-outside from paired pointer events with a scrollbar guard, and treat it as an enhancement rather than an exit. Restore focus from a cleanup effect so every path shares one implementation. Then verify each exit individually — the paths that break are always the ones nobody tried.

A useful habit when reviewing overlay code is to count the exits. If the component has four dismissal paths and only one of them calls the shared close routine, three of them will eventually be reported as bugs — and they will be reported separately, months apart, by users who each found a different one. Consolidating the paths first, and only then adding the conveniences, turns a recurring class of ticket into a single well-tested function that every future exit route can call.

Frequently Asked Questions

Should Escape close a form with unsaved changes? Not silently. Ask for confirmation, and make the confirmation itself accessible: a nested dialog with focus moved into it and focus restored to the original dialog if the user cancels. Discarding work on a single keystroke is a usability failure for everyone and an expensive one for anyone who types slowly.

Is click-outside required by WCAG? No. Escape and a visible close button satisfy the requirements; click-outside is a pointer convenience. What WCAG does require is that whatever dismissal you offer is reachable by keyboard, and that content shown on hover or focus is dismissible without moving the pointer.

Does the native <dialog> element handle all of this? Escape and the top-layer stacking, yes — it fires cancel and closes only the topmost dialog. Focus restoration on close is also handled in current browsers. Click-outside is not built in: a backdrop click does nothing unless you add a handler, which is a reasonable default.

How do I handle Escape inside a nested combobox? The combobox handles Escape first, closing its list and stopping propagation. Only when the list is already closed should the event reach the dialog. That ordering falls out naturally if each component listens on its own element rather than on document.

What about swipe-to-dismiss on touch? Fine as an addition, provided a visible close button remains. A gesture-only dismissal fails 2.5.1 Pointer Gestures for users who cannot perform multipoint or path-based gestures, and it is invisible to anyone who has not been told it exists.