Delegating Focus Across Shadow Boundaries

A custom element that wraps a focusable control inside a shadow root will not receive keyboard focus, and calling host.focus() does nothing, until you explicitly delegate focus through the shadow boundary. This deep-dive isolates exactly why, and shows the production-safe fix using delegatesFocus, :focus-visible, and manual forwarding.

This is one of the most common keyboard-accessibility regressions in design systems built on Accessibility & Focus Management primitives, and it surfaces the moment you put a native control behind a shadow root.

Minimal Reproducible Example

The component below renders an <input> inside its shadow root and exposes a focus() method by inheritance from HTMLElement. Everything looks correct, yet neither clicking the host label area nor calling host.focus() lands the caret in the input.

class TextField extends HTMLElement {
  #root;
  constructor() {
    super();
    this.#root = this.attachShadow({ mode: 'open' }); // no delegatesFocus
    this.#root.innerHTML = `
      <style>:host { display: inline-block; padding: 6px; }</style>
      <input type="text" part="control" />`;
  }
}
customElements.define('text-field', TextField);
<text-field id="tf"></text-field>
<script>
  const tf = document.getElementById('tf');
  tf.focus();
  console.log(document.activeElement === tf);              // false
  console.log(tf.shadowRoot.activeElement);                // null
  // Tabbing reaches <text-field> only if it has a tabindex,
  // and even then focus stays on the host, not the inner <input>.
</script>

The host is not in the tab order at all (a custom element has no implicit tabindex), and host.focus() tries to focus the host itself, which is not a focusable area. The inner <input> is reachable by a mouse click directly on it, but never by host.focus() and never as a single Tab stop representing the component.

Focus with and without delegatesFocus Without delegatesFocus, host.focus() is a no-op and stops at the shadow boundary; with delegatesFocus true, focus is forwarded to the first focusable descendant inside the shadow root. mode: 'open' (default) host.focus() no-op shadow boundary <input> stays unfocused blocked delegatesFocus: true host.focus() :focus matches host shadow boundary <input> focused forwarded document.activeElement === host · shadowRoot.activeElement === <input> Style the delegated target with :focus-visible for a keyboard-only ring

Root-Cause Analysis

The WHATWG HTML Standard defines focus in terms of focusable areas and a “get the focusable area” algorithm. An element is focusable only if it is being rendered, is not inert, and either is a natively focusable element or has a tabindex value. A plain custom element satisfies none of these by default, so it is neither a sequential focus stop nor a valid target for .focus().

Critically, the spec does not automatically retarget host.focus() into the shadow tree. The shadow boundary is opaque to focus by default precisely so that authors stay in control of where focus lands. The platform provides one declarative opt-in: the delegatesFocus option on attachShadow(). When a shadow root is created with delegatesFocus: true, two spec behaviors change. First, the host becomes a delegated focus target: running the focusing steps on the host (via host.focus() or a click anywhere in the host) instead focuses the first focusable descendant in the flattened tree. Second, while any descendant inside the shadow tree has focus, the :focus and :focus-within pseudo-classes also match the host, so focus styling renders where a consumer expects it.

Without that flag, the engine has no instruction to descend, so host.focus() is a no-op and the component cannot present itself as a single, keyboard-reachable control. This is the same boundary discipline established when the root is created in Shadow DOM Construction & Modes: the boundary protects internals until you explicitly open a channel through it.

There is a second, subtler reason the naive component fails sequential navigation. Even if you give the host a tabindex="0" to force it into the Tab order, focus then lands on the host element, not on the inner <input> — so the user must Tab again to reach the actual control, producing a confusing double stop, and the caret never appears where they expect. delegatesFocus solves both problems at once: it makes the host a single Tab stop and forwards the resulting focus into the shadow tree, so one Tab press reaches the component and immediately lands in the input. The flag is therefore not merely a convenience for host.focus(); it is what makes the component behave like a native form control under keyboard navigation.

It is worth being precise about retargeting. From outside the shadow boundary, document.activeElement reports the host, never the inner node, because the platform retargets the active element to the shadow host to preserve encapsulation. From inside, shadowRoot.activeElement reports the real focused descendant. Tests and debugging code that assert against document.activeElement alone will see only the host and can be misled into thinking delegation failed when it actually worked — always cross-check shadowRoot.activeElement.

Production-Safe Fix

The corrected component sets delegatesFocus: true, styles the delegated target with :focus-visible so the focus ring appears only for keyboard users, and provides a manual focus() override for the cases where you need finer control than “first focusable descendant” — for example, focusing a specific inner control or selecting its text.

class TextField extends HTMLElement {
  #root;
  #input;

  constructor() {
    super();
    // delegatesFocus: host.focus() and host clicks now reach the inner input,
    // and :focus / :focus-within match the host while the input is focused.
    this.#root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    this.#root.innerHTML = `
      <style>
        :host { display: inline-block; border-radius: 6px; }
        /* Style the delegated target, not the host, for the keyboard-only ring. */
        input:focus-visible {
          outline: 2px solid var(--focus-ring, #5a7bff);
          outline-offset: 1px;
        }
        /* Suppress the default ring on pointer focus. */
        input:focus:not(:focus-visible) { outline: none; }
      </style>
      <input type="text" part="control" />`;
    this.#input = this.#root.querySelector('input');
  }

  // Manual forwarding: override focus() when you need more than
  // "first focusable descendant" — e.g. select-on-focus behavior.
  focus(options) {
    this.#input.focus(options);
    this.#input.select();
  }
}
customElements.define('text-field', TextField);

With delegatesFocus: true, the host also becomes part of sequential focus navigation as a single Tab stop, so keyboard users reach the component once and land directly in the input — no stray host-then-input double stop. The manual focus() override is optional; omit it when the default “first focusable descendant” behavior is correct, and add it only when you need select-on-focus, conditional targeting, or to skip a non-interactive first child.

The :focus-visible styling deserves attention because it is what separates a polished control from one reviewers reject. With delegatesFocus, the host’s :focus and :focus-within pseudo-classes match while the inner input is focused, but the visible ring should come from the delegated target’s :focus-visible so it appears for keyboard focus and not for pointer clicks. The pattern above styles input:focus-visible for the ring and explicitly suppresses the default outline on input:focus:not(:focus-visible) so a mouse click never draws one. If you prefer to draw the ring on the host instead, use :host(:focus-within) combined with a JavaScript pointer-detection guard, but styling the inner target is simpler and lets the engine’s heuristic do the work.

One caveat with the manual override: once you define focus() on the host, you own its full contract. Forward the options argument (so { preventScroll: true } still works), and do not forget that some callers expect focus() to be idempotent. Keep the override thin — delegate to the inner control’s own focus() and add only the extra behavior you need.

Verification

Confirm the fix from the console. With delegatesFocus, host.focus() lands inside the shadow tree, which you observe through both document.activeElement (which reports the host from the outside, because the boundary retargets) and shadowRoot.activeElement (which reports the real inner node).

const tf = document.querySelector('text-field');
tf.focus();

// From the light DOM, activeElement is retargeted to the host:
console.log(document.activeElement === tf);          // true
console.log(document.activeElement.tagName);         // "TEXT-FIELD"

// Inside the shadow root, the real focused node is the input:
console.log(tf.shadowRoot.activeElement.tagName);    // "INPUT"

// Pointer vs keyboard focus ring:
//  - Tab to the component -> input:focus-visible matches -> ring shows.
//  - Click the component  -> :focus-visible does not match -> no ring.

In DevTools, the Accessibility pane shows the component as a single reachable control, and toggling between Tab and mouse focus demonstrates that the ring only appears for keyboard focus. A useful assertion in tests: after dispatching a Tab key sequence, assert el.shadowRoot.activeElement is the expected inner node rather than checking document.activeElement, which only ever reports the host.

When to Use / When to Avoid

Scenario Approach
Component wraps exactly one focusable control delegatesFocus: true, no manual override
Need select-on-focus or to target a specific inner node delegatesFocus: true plus a manual focus() override
Composite widget with roving tabindex (menu, grid) Avoid delegatesFocus; manage tabindex and focus() manually so arrow keys, not Tab, move the active item
Host must be a focus stop but contains no focusable descendant Use host tabindex="0" instead of delegatesFocus
Multiple focusable descendants, first is not the intended target delegatesFocus lands on the first; add a manual override or reorder DOM
Pointer focus should not show a ring Style :focus-visible, never bare :focus

Avoid delegatesFocus for roving-tabindex composites: delegation always targets the first focusable descendant, which fights a roving-tabindex model where exactly one item should be tabbable at a time. For those, keep the container focusable and move focus with arrow-key handlers as shown in the parent topic’s listbox pattern.