FocusTrapController
Keeps Tab inside a modal surface and returns focus on destroy.
Reach for it for a modal dialog.
Import
import { FocusTrapController, createFocusTrapController } from '@ecopages/radiant-ui/controllers';Try it
Trap focus moves focus into the panel. Tab on Last wraps to First and Shift+Tab on First wraps to Last. Release focus returns focus to the button.
<div data-example="focus-trap">
<button type="button" data-toggle>Trap focus</button>
<div data-box>
<button type="button">First</button>
<button type="button">Last</button>
</div>
</div>import { FocusTrapController } from '@ecopages/radiant-ui/controllers';
export function mount(root: HTMLElement): () => void {
const panel = root.querySelector<HTMLElement>('[data-box]')!;
const toggle = root.querySelector('[data-toggle]')!;
const trap = new FocusTrapController({ getSurface: () => panel });
let trapped = false;
toggle.addEventListener('click', () => {
trapped = !trapped;
if (trapped) {
trap.attach();
} else {
trap.destroy();
}
toggle.textContent = trapped ? 'Release focus' : 'Trap focus';
});
return () => trap.destroy();
}mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.
Config
| Option | Description |
|---|---|
getSurface | The modal surface. null traps nothing. |
getFocusable | The elements Tab loops over, in order. Defaults to the focusable descendants of the surface. |
getStrict | Whether focus inside the surface but outside the loop (the surface itself, a tabindex="-1" node) is pulled into the loop on Tab. Read on every key press. Default false. |
getEnabled | Whether the trap holds Tab. Read on every key press; false leaves Tab to the browser so focus can leave. Default true. |
getReturnFocus | Where focus goes on destroy(). Default: the element focused at attach(), also used when this returns null or an element that is no longer connected. |
Lifecycle
attach() remembers the focused element, listens for Tab on document and, in a microtask, focuses the first focusable element of the surface (or the surface). destroy() stops listening and returns focus to getReturnFocus() when it names a connected element, otherwise to the remembered one. focusFirst() and focusLast() move focus to the first or last of the same list the Tab loop uses (or the surface when it is empty), for code that moves focus inside while attached. A Tab with focus outside the surface is left alone. Making the rest of the page inert is not part of it.
Ways out
destroy()releases the trap and returns focus. This is the normal exit for a modal.getEnabledreturningfalsepauses the loop without detaching, so a non-modal surface can let Tab leave. Initial focus onattach()and the return ondestroy()still happen, and the loop works again when it returnstrue.- Escape is not handled here. DismissController owns it: its
onDismisscallsdestroy(), andgetReturnFocusdecides where focus lands. The modal recipe shows the two together.
With a host, createFocusTrapController(host, config) does the same on connect and disconnect; see bindController.
Protected hooks
None. Change behavior through the config callbacks.