Home / lwc / lwc-focus-management
lwc-focus-management
Focus trap, restore, and programmatic focus in modals, wizards, and dynamic lists. Triggers: focus trap LWC, focus restore, tabindex modal. NOT for general LWC accessibility review — use lwc/lwc-accessibility.
Open the skill folder on GitHub
LWC Focus Management
Focus management in LWC is not "the same as focus management on the web, plus a framework". The shadow boundary changes what the DOM tells you, and almost every focus bug in an LWC bundle traces back to one of two facts:
| Fact | What breaks | Correct instrument |
|---|---|---|
document.activeElement retargets to the host element, not the focused control inside it | document.activeElement === firstElement is always false, so hand-rolled focus traps never engage | this.template.activeElement — the ShadowRoot's own view |
this.template.querySelectorAll('input, button, …') only sees this template | A trap over a form of lightning-inputs finds zero focusable elements | Query the custom elements and let them focus themselves |
Both produce code that looks right, passes review, and does nothing. Neither throws. That is the whole problem: a broken focus trap is indistinguishable from a working one unless you test it with a keyboard.
Scope. This skill owns where focus goes and when: restoration across state changes, traps, programmatic focus across the shadow boundary, and the timing rules that decide whether .focus() lands on anything. Broad accessibility review — labels, contrast, roles, alt text, the full WCAG sweep — is lwc/lwc-accessibility. Modal structure and overlay behaviour is lwc/lwc-lightning-modal and lwc/lwc-modal-and-overlay; this skill covers the focus contract those components have to honour.
Before Starting
- Ask whether you should be writing a trap at all.
lightning/modalshipsone. The Lightning Component Reference documents where it places initial focus, in priority order, and it is maintained by people who test against screen readers you do not have. Hand-rolling is defensible only when the surface genuinely is not a modal.
- Enumerate the state transitions, not the components. Open, close, submit,
validation-failed, async-loaded, row-added, row-removed, step-forward, step-back, error, cancel. Each is a focus decision. A component with nine transitions and one
renderedCallbackfocus call has eight unhandled cases. - For each transition, write down three things, not one: where focus goes,
where it returns to when the state ends, and what assistive technology is told. A focus target without a return target is how users end up on
<body>. - Decide who owns restoration. The element that opened the overlay is the
only thing that reliably knows where focus came from — and it lives in a different shadow root from the overlay. Restoration is the opener's job. Designing it as the overlay's job is the root cause of "focus goes to the top of the page when I close the dialog".
Core Concepts
document.activeElement lies to you across the shadow boundary
This is standard shadow DOM retargeting, not an LWC quirk, and it is the single highest-frequency defect in this area. When focus is on an <input> inside a component's shadow tree, document.activeElement is the host custom element.
// WRONG — inside c-my-modal, with focus on an inner button.
// document.activeElement is <c-my-modal>, so this is never true.
if (document.activeElement === first) { … }
// RIGHT — the ShadowRoot's own activeElement is scoped to this shadow tree.
if (this.template.activeElement === first) { … }
Saving document.activeElement as "the opener" has the same defect: you save the host component, and calling .focus() on a custom element that has neither tabindex nor delegatesFocus does nothing at all. See Pattern B.
this.template.querySelector is scoped, and base components are opaque
this.template.querySelector() reaches into this component's shadow tree and stops. A <lightning-input> in your template is one element to you; the actual <input> lives in its shadow tree and your selector will never match it.
// Finds nothing useful in a form made of base components.
this.template.querySelectorAll('button, input, select, textarea');
// Finds the base components themselves — which is what you want, because
// each one knows how to focus its own internals.
this.template.querySelectorAll('lightning-input, lightning-button, lightning-combobox');
Every relevant base component exposes focus() — lightning-input's is documented as "Sets focus on the input element." Delegate; do not reach in.
this.refs is the modern way to name a target
lwc:ref marks an element in the template and this.refs.<name> retrieves it without a selector. Two restrictions matter here, because focus targets are frequently exactly the things they exclude:
- "If you place
lwc:refin afor:eachoriterator:*loop, the templatecompiler throws an error." Focusing a row in a dynamic list therefore still needs
querySelectorwith adata-*attribute. - "If you call
this.refsfor a nonexistent ref, it returnsundefined" — sothis.refs.firstField?.focus()is the safe shape, and it is silently a no-op before first render.
delegatesFocus is the documented way to make a component focusable
export default class CoolButton extends LightningElement {
static delegatesFocus = true;
}
With this set, calling .focus() on the host delegates to the first focusable element inside, clicking a non-focusable area of the shadow tree delegates focus inward, and :focus applies to the host as well as the focused element. The documentation is explicit about one interaction: "Don't use tabindex with delegatesFocus because it throws off the focus order." Pick one.
The alternative — an @api focus() method that calls this.template.querySelector(...).focus() — also works and is common. Prefer delegatesFocus for leaf components that wrap a single control, and an explicit @api focus() when the component must choose between several internal targets based on state.
tabindex in LWC has exactly two legal values
Only 0 and -1. 0 puts the element in sequential keyboard navigation; -1 takes it out while leaving it programmatically focusable. Positive values are rejected by the template compiler, which is the framework enforcing an accessibility rule the rest of the web only recommends.
By default focus skips a component's container and moves to the elements inside it. To make the component itself a tab stop, add tabindex="0" to the component tag in the parent template — not inside the component.
Timing: renderedCallback, guarded
.focus() on an element that has not rendered is a no-op with no error. connectedCallback is too early — "You can't access child elements from the callbacks because they don't exist yet." renderedCallback "flows from child to parent" and fires after every render, and "A component is usually rendered many times during the lifespan of an application."
The documented pattern for a one-time action is a boolean field:
"To use this hook to perform a one-time operation, use a boolean field like
hasRenderedto track whetherrenderedCallback()has been executed."
Unguarded focus in renderedCallback re-focuses on every render, which eats keystrokes as the user types. The same page carries the harder warning: "Updating the state of your component in renderedCallback() can cause an infinite loop." A focus flag is not reactive state, which is exactly why the flag pattern is safe and setting a @track property there is not.
Common Patterns
Pattern A — let lightning/modal do it
LightningModal places initial focus by a documented rule: the step subtitle if the modal has multiple steps, otherwise the header title, otherwise the first interactive element in the body, otherwise the close button. Reproducing that correctly by hand is more work than it looks and worse than it looks when you get it wrong. Full example in