Manual UI Behaviors focusScope

focusScope

elements man ui/behaviors/focus-scope

Moves focus into a container when it opens, optionally traps it there, and puts it back where it was when the container closes. Scopes stack: a trapped scope opened inside another pauses the outer one, and releasing it resumes the outer one, so nested dialogs each keep their own trap.

Signatures
function focusScope(container: HTMLElement, opts?: FocusScopeOptions): FocusScope; function focusScopeDepth(): number; type FocusTarget = string | HTMLElement | (() => HTMLElement | null);

A FocusTarget is a selector matched inside the container, an element, or a function that returns one.

Options

Option Type Default What it does
trap boolean false Keep focus inside: Tab wraps at the edges and focus moved outside is pulled back.
loop boolean same as trap Tab wraps at the edges.
initialFocus FocusTarget | false see below Where focus goes on open. false leaves it where it is.
returnFocus FocusTarget | false | null the element focused before the scope opened Where focus returns on release. false skips.
inside () => (Element | null | undefined)[] none Extra elements that count as inside the trap: a popover rendered outside the container.

Initial focus, in order: initialFocus; an [autofocus] or [data-autofocus] element inside; the first tabbable element that is not a link; the container itself, given tabindex="-1".

Tab follows the browser's own stops at the edges: a native radio group is one stop on its checked radio, and controls in a disabled fieldset are not stops. Focus returns on release only when it is still inside the closing container (or an inside() element) or was lost to the body; a user who clicked elsewhere keeps their place.

The scope

Member What it does
container The element the scope was opened on.
release(returnFocus?) Stops trapping and returns focus. Pass false to skip returning it. Safe to call twice.
focusFirst(), focusLast() Focus the first or last tabbable element.
focusNext(opts?), focusPrevious(opts?) Focus the element after or before the focused one.

focusNext and focusPrevious take FocusManagerOptions:

Option Type Default What it does
from Element the focused element Start from this element instead.
tabbable boolean false Only elements in the Tab order (tabindex >= 0); otherwise anything focusable.
wrap boolean false Go round at the ends.
accept (el: Element) => boolean none Only elements this returns true for.

In a template of your own

app/shared/templates/side-panel/index.ehtml
import { focusScope, type FocusScope } from "@elements/ui/behaviors"; interface SidePanelState { scope?: FocusScope; } <SidePanel (open: boolean = false, private ui: SidePanelState = {})> <aside e:if={open} class="side-panel" role="dialog" aria-modal="true" oninsert={(e) => ui.scope = focusScope(e.target as HTMLElement, { trap: true })} onremove={() => ui.scope?.release()}> <input data-autofocus placeholder="Search"> <slot/> <button type="button" onclick={() => open = false}>Close</button> </aside> </SidePanel>

The input takes initial focus through data-autofocus, Tab cycles inside the panel, and releasing the scope on removal puts focus back on whatever opened it.

Writes

tabindex="-1" on a container that had no tabindex, removed on release.

Used by

Dialog (opens in a new tab) and Popover (opens in a new tab), and so every template built on them: AlertDialog, Sheet, Drawer, HoverCard, Tooltip, Select, Combobox, DatePicker.