This content originally appeared on DEV Community and was authored by Rob Levin
The Problem
While building a drawer component system, I encountered a peculiar focus management bug. When opening a drawer, the initial focus was supposed to land on the Close button. Instead, the ag-drawer
element itself was receiving focus, and our focus detection utility was returning an empty arrayeven though the Close button was clearly visible and focusable.
The Architecture
Our drawer component has an interesting architecture:
<ag-drawer>
<p>Drawer content</p>
<ag-button>Close</ag-button>
</ag-drawer>
Internally, ag-drawer
renders an ag-dialog
in its Shadow DOM:
// ag-drawer's shadow DOM
render() {
return html`
<ag-dialog
.open=${this.open}
.heading=${this.heading}
// ... other props
>
<slot></slot>
</ag-dialog>
`;
}
So the component hierarchy looks like this:
ag-drawer (light DOM: <ag-button>Close</ag-button>)
shadow root
ag-dialog
shadow root
<slot> (projects ag-drawer's light DOM)
The First Bug: Custom Element Visibility Detection
Our getFocusableElements
utility was filtering out the ag-button
because of this check:
// Exclude elements that are not visible
if (el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') {
return false;
}
Custom elements can have offsetParent === null
even when visible, especially:
- During render cycles
- After parent transitions
- In Shadow DOM contexts
Fix: Skip the offsetParent
check for custom elements:
const isCustomElement = el.tagName.includes('-');
if (!isCustomElement && el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') {
return false;
}
The Second Bug: Wrong Light DOM Search Scope
Even after fixing the custom element detection, the Close button still wasn't found. The issue? We were searching the wrong light DOM container.
ag-dialog
was calling:
getFocusableElements(this.shadowRoot, this) // 'this' is ag-dialog
But the slotted content (Close button) lives in ag-drawer
's light DOM, not ag-dialog
's. ag-dialog
's light DOM is emptyit's just a slot that projects content from its parent host.
Fix: Find the parent host element when inside a shadow root:
private _setInitialFocus() {
// For drawers, the slotted content is in the parent ag-drawer's light DOM
const lightDomContainer = (this.getRootNode() as ShadowRoot).host as HTMLElement || this;
const focusableElements = getFocusableElements(this.shadowRoot, lightDomContainer);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
Now ag-dialog
correctly searches ag-drawer
's light DOM for focusable elements.
Key Takeaways
Custom elements need special visibility handling - Standard DOM visibility checks like
offsetParent === null
can incorrectly filter out custom elements.Understand your Shadow DOM hierarchy - When searching for slotted content, you need to search the correct host's light DOM, not the component doing the rendering.
getRootNode().host
is your friend - It helps you traverse up the Shadow DOM tree to find parent custom elements.
The Result
Focus management now works correctly:
- Close button receives initial focus when drawer opens
- Focus trap properly cycles through all focusable elements
- Custom elements are correctly identified as focusable
This was a great reminder that Shadow DOM creates encapsulation boundaries that require careful thought when implementing cross-boundary features like focus management.
This content originally appeared on DEV Community and was authored by Rob Levin

Rob Levin | Sciencx (2025-10-06T00:33:31+00:00) Fixing Focus Management in Nested Web Components. Retrieved from https://www.scien.cx/2025/10/06/fixing-focus-management-in-nested-web-components/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.