Focus Trap

Keep Tab, Shift+Tab and stray focus inside a panel while it is open - and hand focus back when it closes

Overview

E.focusTrap() is the piece every overlay needs and few get right: while a dialog, drawer or palette is open, a keyboard user must not Tab out into the page behind it. The trap wraps Tab from the last control to the first (and Shift+Tab back), pulls focus back in if a click or a script moves it out, and on deactivation returns focus to whatever had it before.

Key Features

  • Live - tabbable elements are found on every key press, so controls added later take part
  • Correct skipping - disabled, hidden, inert, invisible and tabindex="-1" elements; one stop per radio group
  • Shadow roots - open shadow trees are walked in render order, slotted children at their slot
  • Empty panels - with nothing tabbable the container itself takes focus
  • Stacks - a second trap pauses the first; closing it hands back to the first
  • Optional inert - inert: true shuts the rest of the page off for pointers and screen readers too

Basic Usage

const trap = E.focusTrap('#panel', {initialFocus: '#name'});
trap.activate();     // focus moves in, Tab wraps
trap.deactivate();   // focus goes back to the button that opened it

Trap a panel

Activate the trap, then press Tab a few times: focus goes round the three controls and never reaches the rest of the page. Done deactivates it and focus returns to the Activate button. The Add a button control shows that new controls join the cycle.

Trap a panel
inactive
<div class="sx-trap-row mb-4">
    <button id="basic-on" class="btn btn-primary" type="button">Activate</button>
    <span id="basic-state" class="sx-trap-state">inactive</span>
</div>
<div id="basic-panel" class="sx-trap-panel mb-4">
    <label class="form-label" for="basic-name">Name</label>
    <input id="basic-name" class="form-input mb-3" type="text" placeholder="Ada Lovelace">
    <div id="basic-actions" class="sx-trap-row">
        <button id="basic-add" class="btn btn-outline btn-sm" type="button">Add a button</button>
        <button id="basic-done" class="btn btn-sm btn-success" type="button">Done</button>
    </div>
</div>
/**
 * Trap a panel: Tab wraps inside it, and focus goes back to Activate when it closes.
 */
export function mount(root) {
    const $root = $(root);
    const panel = $root.find('#basic-panel');

    const trap = E.focusTrap(panel.get(0), {
        initialFocus: '#basic-name',
        onActivate: () => panel.addClass('is-trapping'),
        onDeactivate: () => panel.removeClass('is-trapping')
    });
    const showState = () => $root.find('#basic-state').text(trap.isActive() ? 'active - press Tab' : 'inactive');
    trap.on('activate', showState).on('deactivate', showState);

    $root.find('#basic-on').on('click', () => trap.activate());
    $root.find('#basic-done').on('click', () => trap.deactivate());

    // Controls added while the trap is active join the Tab cycle at once.
    let added = 0;
    $root.find('#basic-add').on('click', () => {
        added += 1;
        const button = $('<button class="btn btn-outline btn-sm" type="button"></button>').text('New ' + added);
        $root.find('#basic-done').before(button);
    });

    return () => trap.destroy();
}
const trap = E.focusTrap('#basic-panel', {
    initialFocus: '#basic-name',
    onActivate:   () => $('#basic-panel').addClass('is-trapping'),
    onDeactivate: () => $('#basic-panel').removeClass('is-trapping')
});
$('#basic-on').on('click', () => trap.activate());
$('#basic-done').on('click', () => trap.deactivate());
// later: trap.destroy();

Stacked traps

Traps keep a stack. Activate the outer panel, then the inner one from inside it: the outer trap pauses. Closing the inner panel unpauses the outer one and puts focus back on the button that opened it - exactly what a confirm dialog over a form needs.

Stacked traps
outer: inactive · inner: inactive

Outer panel

Inner panel

<div class="sx-trap-row mb-4">
    <button id="outer-on" class="btn btn-primary" type="button">Activate outer</button>
    <span id="stack-state" class="sx-trap-state">outer: inactive · inner: inactive</span>
</div>
<div id="outer-panel" class="sx-trap-panel mb-4">
    <p class="mb-3">Outer panel</p>
    <div class="sx-trap-row mb-4">
        <button id="inner-on" class="btn btn-outline btn-sm" type="button">Activate inner</button>
        <button id="outer-off" class="btn btn-sm btn-ghost" type="button">Close outer</button>
    </div>
    <div id="inner-panel" class="sx-trap-panel">
        <p class="mb-3">Inner panel</p>
        <div class="sx-trap-row">
            <button class="btn btn-outline btn-sm" type="button">One</button>
            <button class="btn btn-outline btn-sm" type="button">Two</button>
            <button id="inner-off" class="btn btn-sm btn-ghost" type="button">Close inner</button>
        </div>
    </div>
</div>
/**
 * Stacked traps: the inner trap pauses the outer one, and closing it hands focus back.
 */
export function mount(root) {
    const $root = $(root);
    const marker = (panel) => ({
        onActivate: () => panel.addClass('is-trapping'),
        onDeactivate: () => panel.removeClass('is-trapping')
    });

    const outerPanel = $root.find('#outer-panel');
    const innerPanel = $root.find('#inner-panel');
    const outer = E.focusTrap(outerPanel.get(0), marker(outerPanel));
    const inner = E.focusTrap(innerPanel.get(0), marker(innerPanel));

    const said = (t) => (t.isPaused() ? 'paused' : t.isActive() ? 'active' : 'inactive');
    const showState = () => $root.find('#stack-state').text('outer: ' + said(outer) + ' · inner: ' + said(inner));
    for (const trap of [outer, inner]) {
        for (const name of ['activate', 'deactivate', 'pause', 'unpause']) trap.on(name, showState);
    }

    $root.find('#outer-on').on('click', () => outer.activate());
    $root.find('#outer-off').on('click', () => outer.deactivate());
    $root.find('#inner-on').on('click', () => inner.activate());
    $root.find('#inner-off').on('click', () => inner.deactivate());

    return () => {
        inner.destroy();
        outer.destroy();
    };
}
const outer = E.focusTrap('#outer-panel');
const inner = E.focusTrap('#inner-panel');
outer.activate();
inner.activate();    // outer.isPaused() === true
inner.deactivate();  // outer listens again; focus back on "Activate inner"

Escape, outside clicks and inert

By default the trap ignores Escape (the overlay that owns it decides what Escape means) and blocks clicks outside. This panel turns on escapeDeactivates and clickOutsideDeactivates, and inert: true - while it is active the rest of the page is inert, so a screen reader's virtual cursor cannot reach it either. It has nothing tabbable inside, so the panel itself takes focus.

Escape, outside clicks and inert
inactive

Nothing to press in here. Press Esc or click anywhere outside.

<div class="sx-trap-row mb-4">
    <button id="opts-on" class="btn btn-primary" type="button">Activate</button>
    <span id="opts-state" class="sx-trap-state">inactive</span>
</div>
<div id="opts-panel" class="sx-trap-panel mb-4" aria-label="Notice">
    <p class="m-0">Nothing to press in here. Press <kbd>Esc</kbd> or click anywhere outside.</p>
</div>
/**
 * Escape, outside clicks and inert: the panel closes either way, and the page behind is inert meanwhile.
 */
export function mount(root) {
    const $root = $(root);
    const panel = $root.find('#opts-panel');

    const trap = E.focusTrap(panel.get(0), {
        escapeDeactivates: true,
        clickOutsideDeactivates: true,
        inert: true,
        onActivate: () => panel.addClass('is-trapping'),
        onDeactivate: () => panel.removeClass('is-trapping')
    });
    const showState = () => $root.find('#opts-state')
        .text(trap.isActive() ? 'active - the rest of the page is inert' : 'inactive');
    trap.on('activate', showState).on('deactivate', showState);

    $root.find('#opts-on').on('click', () => trap.activate());

    // destroy() deactivates first, so the page loses its inert again.
    return () => trap.destroy();
}
E.focusTrap('#opts-panel', {
    escapeDeactivates: true,
    clickOutsideDeactivates: true,
    inert: true
}).activate();

Events

activate, deactivate, pause and unpause - as onX options, .on('x') and dm:focus-trap:x DOM events on the (first) container. The DOM events bubble, so this log hears both traps with one handler on the demo: open the confirm to see the form trap pause, and close it to see it unpause.

Events

Form panel

Confirm panel

  • Nothing yet - activate the panel.
<div class="sx-trap-row mb-4">
    <button id="ev-on" class="btn btn-primary" type="button">Activate</button>
</div>
<div id="ev-panel" class="sx-trap-panel mb-4">
    <p class="mb-3">Form panel</p>
    <div class="sx-trap-row mb-4">
        <button id="ev-confirm-on" class="btn btn-outline btn-sm" type="button">Open confirm</button>
        <button id="ev-off" class="btn btn-sm btn-ghost" type="button">Close</button>
    </div>
    <div id="ev-confirm" class="sx-trap-panel">
        <p class="mb-3">Confirm panel</p>
        <button id="ev-confirm-off" class="btn btn-sm btn-success" type="button">OK</button>
    </div>
</div>
<ul id="ev-log" class="sx-trap-log mb-4">
    <li>Nothing yet - activate the panel.</li>
</ul>
/**
 * Events: every trap fires dm:focus-trap:<name> on its container, so one listener hears them all.
 */
export function mount(root) {
    const $root = $(root);
    const names = {'ev-panel': 'Form', 'ev-confirm': 'Confirm'};
    const events = ['activate', 'deactivate', 'pause', 'unpause'].map((name) => 'dm:focus-trap:' + name).join(' ');

    const log = (message) => {
        const list = $root.find('#ev-log');
        list.find('li').filter((i, el) => /^Nothing yet/.test(el.textContent)).remove();
        list.prepend($('<li></li>').text(D().format('HH:mm:ss') + ' - ' + message));
        list.find('li').filter((i) => i >= 8).remove();
    };

    const marker = (panel) => ({
        onActivate: () => panel.addClass('is-trapping'),
        onDeactivate: () => panel.removeClass('is-trapping')
    });
    const formPanel = $root.find('#ev-panel');
    const confirmPanel = $root.find('#ev-confirm');
    const form = E.focusTrap(formPanel.get(0), marker(formPanel));
    const confirm = E.focusTrap(confirmPanel.get(0), marker(confirmPanel));

    // The DOM events bubble, so one handler on the demo hears both traps.
    const onTrapEvent = (e) => log(names[e.detail.instance.element.id] + ': ' + e.type.replace('dm:focus-trap:', ''));
    $root.on(events, onTrapEvent);

    $root.find('#ev-on').on('click', () => form.activate());
    $root.find('#ev-off').on('click', () => form.deactivate());
    $root.find('#ev-confirm-on').on('click', () => confirm.activate());
    $root.find('#ev-confirm-off').on('click', () => confirm.deactivate());

    return () => {
        $root.off(events, onTrapEvent);
        confirm.destroy();
        form.destroy();
    };
}
const events = 'dm:focus-trap:activate dm:focus-trap:deactivate dm:focus-trap:pause dm:focus-trap:unpause';
$('#demo').on(events, (e) => console.log(e.type, e.detail.instance.element.id));

API

// containers: a selector, an element or an array; null when nothing matched
const trap = E.focusTrap(containers, {
    initialFocus,                   // selector | element | () => element | false
    fallbackFocus,                  // when nothing is tabbable (default: the container)
    returnFocus: true,              // true | false | selector | element
    escapeDeactivates: false,       // boolean | (event) => boolean
    clickOutsideDeactivates: false, // boolean | (event) => boolean
    allowOutsideClick: false,       // boolean | (event) => boolean - false blocks them
    allowOutsideFocus: false,       // true | 'layers' (toasts, popovers...) | (el, event) => boolean
    allowScriptFocus: false,        // a handler inside may move focus out on purpose
    inert: false,
    shadow: true,                   // walk open shadow roots
    preventScroll: true,
    register: true,                 // false keeps E.get(container) on its owner
    onActivate, onDeactivate, onPause, onUnpause
});

trap.activate({initialFocus}); trap.deactivate({returnFocus: false});
trap.pause(); trap.unpause({focus: false});
trap.updateContainers(['#dialog', '#toolbar']);
trap.containers(); trap.isActive(); trap.isPaused(); trap.tabbables(); trap.destroy();

E.focusTrap.tabbables(el);   // what Tab would reach, in order
E.focusTrap.top();           // the trap that currently listens