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 Keyboard2.1.2 No Keyboard Trap2.4.3 Focus Order1.4.13 Content on Hover or Focus
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, notkeyup— 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.
Implementation Guidelines:
- Require both
pointerdownandpointerupto 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
pointerdowntarget, 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.1failure.
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.activeElementat 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
focusouton the wrapper and close whenevent.relatedTargetis 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.13requires 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 Trapfailure.
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
clickalone. 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.
Related guides
- Keyboard Navigation Patterns for Modals — the parent guide covering traps and focus order.
- Fixing Focus Trap Issues in React Portals — where the trigger reference tends to get lost.
- Building Accessible Dropdowns Without External UI Kits — the non-modal case in detail.
- Handling Accessible Modals in Next.js 14 Server Components — the server/client split for the same component.