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

OptionDescription
getSurfaceThe modal surface. null traps nothing.
getFocusableThe elements Tab loops over, in order. Defaults to the focusable descendants of the surface.
getStrictWhether 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.
getEnabledWhether the trap holds Tab. Read on every key press; false leaves Tab to the browser so focus can leave. Default true.
getReturnFocusWhere 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.
  • getEnabled returning false pauses the loop without detaching, so a non-modal surface can let Tab leave. Initial focus on attach() and the return on destroy() still happen, and the loop works again when it returns true.
  • Escape is not handled here. DismissController owns it: its onDismiss calls destroy(), and getReturnFocus decides 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.