Focus helpers
Helpers for capturing, moving, and restoring focus in dialogs and other app interactions.
Capture and restore application focus
Call these helpers from dialogs and other interactions that your app controls. Do not move focus for streaming text or routine status updates.
import { captureFocus, focusElement, restoreFocus } from "@generative-a11y/dom";
const capture = captureFocus(document);
const opened = focusElement(dialogHeading, { preventScroll: true });
// After the application-owned interaction closes:
const restored = restoreFocus(capture, {
onlyIfFocusWithin: dialog,
});captureFocus records the active element without moving it. Choose a focusable
dialog target; onlyIfFocusWithin preserves the user’s new focus if they have
moved outside the dialog before it closes.
The helpers are shadow-DOM aware. Capture resolves the deep active element by
descending into shadow roots, and the eligibility checks walk the composed
tree, so hidden, aria-hidden, and inert on shadow hosts are honored.
Prop
Type
The capture snapshot
FocusCapture is a frozen { document, target } snapshot. It never changes
after creation, so a capture taken before a dialog opens stays valid while the
dialog is open, even as focus moves around.
The target is the deep active element at capture time: the helper descends
into shadow roots, so focus inside a web component is recorded as the inner
element, not the host. Three situations produce a null target, and all three
make restoreFocus report skipped with reason unavailable:
- No document exists (server-side rendering or a deleted global).
document.bodyor the document element itself is focused. There is no meaningful element to restore to.- The document throws on property access. A hostile document is captured without a restorable target instead of throwing.
The document field is null only when no document was available. Passing an
explicit document is the way to capture focus for an iframe or a secondary
document: captureFocus(iframe.contentDocument). The default is the global
document when one exists.
FocusResult
Results are explicit so applications can test focus workflows without guessing.
| Status | Meaning | Application response |
|---|---|---|
| focused | Target became active | Continue the workflow |
| skipped | A documented eligibility check, browser failure, or restoration guard prevented focus | Preserve current focus and inspect reason |
Skipped reasons
Every skip carries a stable reason string. Check result.reason in tests
instead of guessing why focus did not move.
| Reason | Meaning |
|---|---|
| unavailable | No usable document, element, or DOM access. Also used for hostile documents and property reads that throw. |
| cross-document | The captured target now belongs to a different document than the capture. |
| disconnected | Target is no longer connected to the document. |
| disabled | Target is disabled via the disabled attribute, the disabled property,
or a |
| hidden | Target or a composed-tree ancestor has the hidden attribute. |
| aria-hidden | Target or a composed-tree ancestor has aria-hidden="true". |
| inert | Target or a composed-tree ancestor is inert. |
| missing-focus | Target has no callable focus() function. |
| guard-mismatch | The onlyIfFocusWithin guard failed: the guard is in another document, nothing is focused, or focus is outside the guard. |
| focus-error | focus() threw, or the active element could not be read afterward. The previous focus is restored on a best-effort basis. |
| focus-not-applied | focus() returned without throwing, but the element did not become the active element. |
Walkthrough
Capture current focus
Call captureFocus before the dialog opens, while the user's original focus
is still in place. The returned snapshot is frozen: { document, target }.
target is the deep active element, so focus inside a web component's shadow
root is captured correctly. When document.body or the document element
itself is focused, or when no document exists, target is null and
restoreFocus later reports skipped with reason unavailable.
const capture = captureFocus(document);
openDialog(dialog);Capture is defensive. A hostile document that throws on property access is captured without a restorable target instead of throwing.
Focus the dialog
Move focus to a target your app controls, such as the dialog heading with
tabindex="-1". focusElement runs the eligibility checks first and only
then calls focus({ preventScroll: true }) by default, so the page does not
jump on long documents.
const opened = focusElement(dialogHeading, { preventScroll: true });
if (opened.status === "skipped") {
console.warn("Dialog focus skipped:", opened.reason);
}Always inspect the result. A skipped result tells you exactly why focus did
not move, which is the difference between a flaky dialog and a testable one.
Restore when appropriate
After the interaction closes, restore the captured focus. The
onlyIfFocusWithin guard keeps the user's new focus when they have already
moved outside the dialog before it closed, which is the polite behavior for
modeless surfaces.
const restored = restoreFocus(capture, { onlyIfFocusWithin: dialog });restoreFocus revalidates everything: the capture is read defensively, a
cross-document target is rejected, the guard is checked against the current
deep active element, and the final move goes through the same eligibility
checks as focusElement.
Eligibility checks
focusElement checks eligibility before calling focus(), and checks again
afterward. The pre-checks run in this order: unavailable (no owner document,
not a real element, or DOM access throws), disconnected, disabled, hidden,
aria-hidden, inert, and missing-focus. Hidden, aria-hidden, and inert are
evaluated up the composed tree, so attributes on shadow hosts and slot
ancestors count.
If focus() throws, the helper attempts a best-effort rollback: it restores
the previously focused element with preventScroll: true, but only when the
previous element is still eligible and the failed target actually received
focus. The result is skipped with reason focus-error. When focus()
returns but the element never became active, the result is skipped with
reason focus-not-applied.
The composed-tree walk is what makes the checks shadow-DOM correct. Instead of
only looking at parentElement, the walk follows assigned slots and shadow
hosts, so hidden on a slot ancestor or inert on a custom element host is
detected. Cycles are guarded with a visited set: a cyclic tree reports
unavailable rather than hanging.
Testing focus workflows
Because every outcome is a plain object, focus workflows are straightforward
to test in jsdom or a real browser. Assert on status for the happy path and
on reason for the guarded paths.
import {
captureFocus,
focusElement,
restoreFocus,
} from "@generative-a11y/dom";
test("restores focus after the dialog closes", () => {
const trigger = document.getElementById("open-dialog")!;
trigger.focus();
const capture = captureFocus(document);
const dialog = document.getElementById("dialog")!;
const heading = dialog.querySelector("h2")!;
expect(focusElement(heading)).toMatchObject({ status: "focused" });
// The dialog closed with focus still inside it, so the guard passes and
// focus returns to the trigger.
const restored = restoreFocus(capture, { onlyIfFocusWithin: dialog });
expect(restored).toMatchObject({ status: "focused", target: trigger });
});
test("skips restoration when the trigger was removed", () => {
const trigger = document.getElementById("open-dialog")!;
trigger.focus();
const capture = captureFocus(document);
trigger.remove();
const restored = restoreFocus(capture);
expect(restored).toMatchObject({ status: "skipped", reason: "disconnected" });
});Note the second test: removing the trigger makes restoration skip with reason
disconnected instead of throwing or moving focus somewhere unexpected. That
is the behavior to rely on, not to work around.
Common pitfalls
- Capturing after the dialog opens. Capture first. A capture taken after
opening records an element inside the dialog, and restoring it after the
dialog is removed skips with reason
disconnected. - Restoring into a removed trigger. If the originally focused element was
removed from the DOM while the dialog was open, restoration skips as
disconnected. Leave focus where it is or choose a fallback target. - Moving focus for streaming updates. These helpers are for application-owned interactions like dialogs. Streaming text and routine status updates should use announcements, not focus moves.
- Ignoring the result.
focusElementandrestoreFocusnever throw for eligibility problems; they report. Branch onstatusand logreasoninstead of assuming focus moved.