Accessibility · Keyboard and Focus
Focus Across Shadow Roots and Frame Boundaries
Where document.activeElement points when focus enters a shadow tree or an embedded document, what delegatesFocus changes, and why a custom element that contains a field is not a tab stop by default.
Focus is a property of a document, and both shadow trees and embedded documents create places where
the document boundary and the element tree diverge. The divergence is small in markup terms — one
extra level of indirection in activeElement — and it changes what a component author must do to
make a wrapper around an input behave like an input.
activeElement is per-document, and shadow trees have a host
A shadow root is not a document. It has no activeElement of its own. Focus inside a shadow tree is
recorded on the document that contains the host, and document.activeElement returns the host
element, not the focused descendant. To reach the actual focused node, code must descend:
function deepActiveElement() {
let el = document.activeElement;
// Each shadow root hides one level; a nested component hides another.
while (el?.shadowRoot?.activeElement) {
el = el.shadowRoot.activeElement;
}
return el;
}
ShadowRoot.activeElement exists for exactly this purpose and returns the focused element within
that root, or null when focus is elsewhere. The loop is necessary because components nest: a
<my-field> containing a <my-input> containing an <input> requires two descents, and a helper
that only checks one level returns the inner host instead of the field.
Two consequences follow for any code that inspects focus. A test asserting
document.activeElement === input fails when the input lives in a shadow root, and the correct
assertion targets the host. And a focusin listener on the document sees events retargeted to the
host, so event.target is the host rather than the inner element — event retargeting applies to
focus events the same way it applies to any other event crossing a shadow boundary.
The tab sequence is computed across the flattened tree, so an element inside a shadow root is a
normal stop in the page’s sequence at the position of its host. Nothing special is required to make
inner elements tabbable; they are tabbable already, which is why a component that wraps an input in
a shadow root needs no tabindex to make the input reachable.
The wrapper problem and delegatesFocus
A custom element that renders a text input in its shadow root is not itself focusable. Clicking it focuses the inner input; the host receives no focus. That is usually fine, and it breaks in two situations.
The first is :focus styling and focus-within on the host. Without delegation, the host does not
match :focus when the inner input is focused, though :focus-within does match. Styles that
depend on :focus on the host — a border, a ring, a state class — do not apply.
The second is clicking on the host’s padding or border area, outside the inner input. The click lands on the host, which is not focusable, so nothing is focused and the user’s click does nothing. This is the behaviour users read as “the field is broken”.
delegatesFocus: true, passed to attachShadow(), changes both. With it, focusing the host forwards
focus to the first focusable descendant in the shadow tree, and a click on a non-focusable part of
the host is treated as a click on the host for focus purposes and forwards accordingly. The host also
becomes focusable in its own right for the purpose of the delegation, without being added to the tab
sequence as a separate stop.
class SearchField extends HTMLElement {
constructor() {
super();
// Delegation must be decided at attach time; it cannot be changed afterwards.
const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
root.innerHTML = `
<style>:host(:focus) { outline: 2px solid currentColor; }</style>
<input type="search" part="input" aria-label="Search">
`;
}
}
customElements.define('search-field', SearchField);
The :host(:focus) rule now matches when the inner input holds focus, because delegation makes the
host participate in the focus state. Without delegation, the same rule would need :host(:focus-within).
Delegation has limits worth knowing. It applies only when the host itself is focused programmatically
or by click; it does not merge the host and the descendant into one element for event purposes, and
the inner element still receives the focus events. It also does not make the host a tab stop: Tab
still lands on the inner input directly, and the host is not visited separately. A component that
wants the host to be the single tab stop — the composite pattern — must set tabindex on the host
and manage focus inside, which is the roving approach described in Roving tabindex and Active
Descendant Compared.
Nested roots and the ordering rule
The tab sequence is computed over the flattened tree, and tabindex values are compared across
shadow boundaries. A tabindex="1" inside a shadow root therefore participates in the same positive
group as a tabindex="1" in the light DOM, which is one more reason positive values are unsafe: the
author of a component cannot see the page’s other values, and the component’s ordering becomes a
function of what it is embedded in.
Focusability is likewise evaluated against the composed tree. An element inside a shadow root whose
host is display: none is not rendered and not focusable, even though the element’s own styles say
nothing about it. The inert attribute on a host inerts the shadow content too, since inertness is
computed on the flat tree.
Nested roots are also where the boundary becomes visible to focus tracking: a focusin listener on
the document fires with the host as its target, so any handler that needs the real element must
descend as deepActiveElement() does. The events themselves are described in The Order of Focus and
Blur Events.
Slots introduce the one genuinely confusing case. An element assigned to a <slot> is a child of the
host in the light tree and a child of the slot in the flattened tree. Focus order follows the
flattened tree, so a slotted element’s position in the sequence is the slot’s position inside the
shadow root, not its position among the host’s children. A component that renders its slots in an
unexpected order — footer slot before header slot — reorders the tab sequence of the content passed
into it, invisibly to the author of that content.
Frames are separate documents
An <iframe> contributes exactly one stop to its parent’s sequence: the frame element itself.
Everything inside is a separate document with its own activeElement, its own starting point, and
its own tab cycle. Tab from the last element inside a frame moves to the browser chrome, not to the
next element in the parent document, in most engines — the frame boundary is a hard edge in the
sequence, not a soft one.
This has three practical effects.
document.activeElement in the parent returns the <iframe> element when focus is anywhere inside
the frame. To find the real target, code must reach into iframe.contentDocument.activeElement,
which is only possible for same-origin frames. A cross-origin frame’s focus state is unreadable, and
a component that needs to know whether focus is inside its embedded content cannot determine it.
Keyboard handlers in the parent do not receive key events from inside a frame. A focus trap
implemented as a keydown listener on a modal container therefore does not contain focus that has
moved into an embedded frame within the modal. inert on the frame element does remove it from the
sequence entirely, which is the correct way to exclude it while a modal is open; the trap’s own
handler cannot do it.
A frame whose document has not loaded cannot receive focus, so contentWindow.focus() before load
is silently ignored. Code that focuses into a frame on demand must wait for the load event or for a
document-ready signal from the frame, and must handle the frame being cross-origin, where the call
is blocked by the same-origin policy rather than failing silently.
The iframe element also accepts tabindex, and setting it to -1 removes the frame’s single stop
while leaving the content reachable if the frame’s own document moves focus into it. That combination
— a frame that is not in the sequence but whose content can still be focused programmatically — is
rarely what an author wants, and it produces a state where keyboard users cannot enter the frame at
all.
Testing what the boundary hides
The observable behaviour that matters is the tab sequence, and it is the one thing no static
inspection reveals. Walking the sequence with repeated Tab presses and logging
deepActiveElement() at each step shows where shadow boundaries collapse to a host and where a
frame’s content is a single stop. The log also exposes the common failure in components: an inner
element carrying a positive tabindex that was copied from an example, which pulls the whole
component to the front of the page’s sequence.
The other observable is the announced name. A host element with no accessible name contributes
nothing to the announcement of its inner content, because the inner input carries its own name. A
host with role="group" and a label contributes a group name around it. Neither is automatic, and
neither is visible in the rendered page.