{"id":9073,"date":"2026-03-24T18:19:54","date_gmt":"2026-03-24T23:19:54","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=9073"},"modified":"2026-03-24T18:19:55","modified_gmt":"2026-03-24T23:19:55","slug":"shadow-dom-focus-delegation-getting-delegatesfocus-right","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/shadow-dom-focus-delegation-getting-delegatesfocus-right\/","title":{"rendered":"Shadow DOM Focus Delegation: Getting\u00a0delegatesFocus\u00a0Right"},"content":{"rendered":"\n<p>Focus management in the shadow DOM is one of those things that is easy to get subtly wrong. You build a clean <code>&lt;my-button><\/code> wrapper around a native <code>&lt;button><\/code>, add a manual <code>focus()<\/code> override that pokes into the shadow root, ship it, and call it done. It works. But there is a cleaner way, and it has been sitting in the spec the whole time.<\/p>\n\n\n\n<p>That cleaner way is <code>delegatesFocus<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-delegatesfocus-does\">What <code>delegatesFocus<\/code> Does<\/h2>\n\n\n\n<p>When you attach a shadow root with <code>delegatesFocus: true<\/code>, the browser takes on a few responsibilities that you would otherwise handle manually.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1) Clicks on the Host Element<\/h3>\n\n\n\n<p>Any click on the host element (including padding areas and decorative regions outside the inner control) automatically forwards focus to the first focusable element inside the shadow root.<\/p>\n\n\n\n<p>No\u00a0<code>this.shadowRoot.querySelector('button').focus()<\/code>\u00a0required.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"566\" height=\"542\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562538168-c11d6a9a-c78b-4e68-b00d-d9a8e85a73b7.png?resize=566%2C542&#038;ssl=1\" alt=\"clicking anywhere on host forwards focus to first focusable element inside the shadow dom\" class=\"wp-image-9078\" style=\"width:438px;height:auto\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562538168-c11d6a9a-c78b-4e68-b00d-d9a8e85a73b7.png?w=566&amp;ssl=1 566w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562538168-c11d6a9a-c78b-4e68-b00d-d9a8e85a73b7.png?resize=300%2C287&amp;ssl=1 300w\" sizes=\"auto, (max-width: 566px) 100vw, 566px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">2) Selector Matching<\/h3>\n\n\n\n<p>The host element matches both the <code>:focus<\/code> and <code>:focus-within<\/code> CSS pseudo-classes whenever an internal element is focused. While <code>:focus-within<\/code> normally applies to any ancestor of a focused element, the less obvious behavior occurs with <code>:focus<\/code>. When <code>delegatesFocus: true<\/code> is set, the shadow host matches <code>:focus<\/code> as if it were the focused element itself. This allows you to style the host&#8217;s focus ring using CSS alone, rather than toggling classes via JavaScript.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"566\" height=\"464\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562541586-204b3b21-acd8-4cac-9c1b-bbdbae2127f3.png?resize=566%2C464&#038;ssl=1\" alt=\"The host element matches both the `:focus` and `:focus-within` CSS pseudo-classes whenever an internal element is focused\" class=\"wp-image-9081\" style=\"width:438px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562541586-204b3b21-acd8-4cac-9c1b-bbdbae2127f3.png?w=566&amp;ssl=1 566w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562541586-204b3b21-acd8-4cac-9c1b-bbdbae2127f3.png?resize=300%2C246&amp;ssl=1 300w\" sizes=\"auto, (max-width: 566px) 100vw, 566px\" \/><figcaption class=\"wp-element-caption\">It is important to note, however, that the <strong>host element itself does not receive focus<\/strong>; focus is strictly delegated to the internal element, which remains the <code>activeElement<\/code> in the DOM.<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">3) No Stranded Focus<\/h3>\n\n\n\n<p>It ensures accessibility by eliminating &#8220;dead focus&#8221; zones. Without delegation, clicking a host\u2019s padding or decorative area can leave focus stranded on a non-interactive element. For keyboard and screen reader users, this creates a broken experience where the component appears active but has no functional focus. <code>delegatesFocus<\/code> closes this gap at the platform level, guaranteeing that any interaction with the host reliably moves focus to the internal control.<\/p>\n\n\n\n<p>These behaviors together eliminate the most common boilerplate found in simple wrapper components.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"opting-in-with-lit\">Opting-In with Lit<\/h2>\n\n\n\n<p>Lit exposes shadow root configuration through a static class property. The opt-in is a one-line addition to LitElement&#8217;s own <code>shadowRootOptions<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">AgButton<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">LitElement<\/span> <\/span>{\n  <span class=\"hljs-keyword\">static<\/span> shadowRootOptions = {\n    ...LitElement.shadowRootOptions,\n    <span class=\"hljs-attr\">delegatesFocus<\/span>: <span class=\"hljs-literal\">true<\/span>,\n  };\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Spreading <code>LitElement.shadowRootOptions<\/code> is important. It preserves Lit&#8217;s own defaults (like <code>mode: 'open'<\/code>) so you are not accidentally overwriting them. Browser support is excellent: <code>delegatesFocus<\/code> has been available in all major engines for years and requires no polyfill.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"when-to-use-it-and-when-not-to\">When to Use It, and When Not To<\/h2>\n\n\n\n<p>Using the <code>delegatesFocus<\/code> property is a good fit for <strong>simple wrapper components<\/strong> that each wrap a single native focusable element. If clicking the host should always move focus to one predictable target, the browser can handle that automatically.<\/p>\n\n\n\n<p>For <a href=\"https:\/\/www.agnosticui.com\/\">AgnosticUI<\/a>, the right candidates are clear:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Component<\/th><th class=\"has-text-align-left\" data-align=\"left\">Use <code>delegatesFocus<\/code>?<\/th><th class=\"has-text-align-left\" data-align=\"left\">Reason<\/th><\/tr><\/thead><tbody><tr><td><code>&lt;ag-button&gt;<\/code><\/td><td>Yes<\/td><td>Single <code>&lt;button&gt;<\/code>, unambiguous focus target<\/td><\/tr><tr><td><code>&lt;ag-input&gt;<\/code><\/td><td>Yes<\/td><td>Single <code>&lt;input&gt;<\/code> or <code>&lt;textarea&gt;<\/code>, same pattern<\/td><\/tr><tr><td><code>&lt;ag-select&gt;<\/code><\/td><td>Yes<\/td><td>Single <code>&lt;select&gt;<\/code>, identical case<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>But <code>delegatesFocus<\/code> is not always appropriate. Components that manage their own focus routing should opt out:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Roving tabindex components<\/strong>&nbsp;(like&nbsp;<code>&lt;ag-tabs&gt;<\/code>): The host element itself must be reachable by keyboard. Delegating away from it breaks the pattern.<\/li>\n\n\n\n<li><strong>Multiple internal focus targets<\/strong>&nbsp;(like&nbsp;<code>&lt;ag-combobox&gt;<\/code>): The component has an input, a toggle, a clear button, and potentially removable badges. Automatic delegation to the &#8220;first focusable element&#8221; would interfere with carefully managed navigation between those targets.<\/li>\n\n\n\n<li><strong>Custom pointer handling<\/strong>&nbsp;(like&nbsp;<code>&lt;ag-slider&gt;<\/code>&nbsp;or&nbsp;<code>&lt;ag-rating&gt;<\/code>): These components use&nbsp;<code>setPointerCapture<\/code>&nbsp;or manage&nbsp;<code>tabindex<\/code>&nbsp;on internal elements dynamically. Letting the browser redirect focus automatically creates conflicts.<\/li>\n<\/ul>\n\n\n\n<p>The rule is simple: if there is only one place focus should ever go, delegate. If the component decides where focus goes, keep control.<\/p>\n\n\n\n<p class=\"learn-more\"><strong>One more pitfall: do not add <code>tabindex<\/code> to the host when using <code>delegatesFocus<\/code>.<\/strong> Setting <code>tabindex=\"0\"<\/code> on the host creates two stops in the tab order where there should be one: the host receives focus on the first Tab, then the inner element receives it on the next. This breaks the expected navigation flow for keyboard users. With <code>delegatesFocus<\/code>, the host participates in focus routing automatically. Adding a manual <code>tabindex<\/code> on top of it interferes with that and produces confusing, inaccessible behavior.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"843\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562546388-b9945e92-680e-48d7-8691-aa83b7358c7a.png?resize=1024%2C843&#038;ssl=1\" alt=\"Comparison of focus behavior in two scenarios: one using tabindex='0' showing two tab stops, and the other using delegatesFocus only showing one tab stop.\" class=\"wp-image-9085\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562546388-b9945e92-680e-48d7-8691-aa83b7358c7a.png?resize=1024%2C843&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562546388-b9945e92-680e-48d7-8691-aa83b7358c7a.png?resize=300%2C247&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562546388-b9945e92-680e-48d7-8691-aa83b7358c7a.png?resize=768%2C633&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/562546388-b9945e92-680e-48d7-8691-aa83b7358c7a.png?w=1440&amp;ssl=1 1440w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-we-changed-in-agnosticui\">What We Changed in AgnosticUI<\/h2>\n\n\n\n<p>The only components we deemed appropriate for utilizing <code>delegatesFocus<\/code> were <code>AgButton<\/code>, <code>AgInput<\/code>, and <code>AgSelect<\/code>. The implementation was the same in each case.<\/p>\n\n\n\n<p><strong>Add <code>shadowRootOptions<\/code>:<\/strong><\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">static<\/span> shadowRootOptions = {\n  ...LitElement.shadowRootOptions,\n  <span class=\"hljs-attr\">delegatesFocus<\/span>: <span class=\"hljs-literal\">true<\/span>,\n};<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"learn-more\"><strong>A note on <code>autofocus<\/code>.<\/strong> For page-load focus, use the <code>autofocus<\/code> attribute on the inner element rather than JavaScript. With <code>delegatesFocus<\/code>, the host correctly reflects this state. However, use it sparingly: jumping focus can disorient screen reader and keyboard users. Reserve <code>autofocus<\/code> for specific interactions, like a dedicated search page or a modal triggered by the user.<\/p>\n\n\n\n<p><strong>Remove the manual <code>focus()<\/code> and <code>blur()<\/code> overrides.<\/strong> Each of the components had something like this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Before: manual delegation<\/span>\nfocus() {\n  <span class=\"hljs-keyword\">this<\/span>.shadowRoot?.querySelector(<span class=\"hljs-string\">'button'<\/span>)?.focus();\n}\nblur() {\n  <span class=\"hljs-keyword\">this<\/span>.shadowRoot?.querySelector(<span class=\"hljs-string\">'button'<\/span>)?.blur();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>With <code>delegatesFocus: true<\/code>, calling <code>.focus()<\/code> on the host element automatically delegates to the inner native element. The manual overrides became dead code and were removed.<\/p>\n\n\n\n<p><strong>Retain focus and blur re-dispatch handlers.<\/strong> Removing manual <code>focus()<\/code> and <code>blur()<\/code> overrides does not affect our <code>@focus<\/code> and <code>@blur<\/code> listeners. These address a separate concern: event bubbling.<\/p>\n\n\n\n<p>While <code>delegatesFocus<\/code> routes focus <em>into<\/em> the shadow root, native <code>focus<\/code> and <code>blur<\/code> events remain trapped inside it. We must keep these internal listeners to re-dispatch events as bubbling, composed events from the host. This ensures <code>addEventListener('focus', ...)<\/code> works for the consumer:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ untouched: delegatesFocus does not handle event bubbling<\/span>\nprivate _handleFocus(event: FocusEvent) {\n  <span class=\"hljs-keyword\">this<\/span>.dispatchEvent(<span class=\"hljs-keyword\">new<\/span> FocusEvent(<span class=\"hljs-string\">'focus'<\/span>, {\n    <span class=\"hljs-attr\">bubbles<\/span>: <span class=\"hljs-literal\">true<\/span>,\n    <span class=\"hljs-attr\">composed<\/span>: <span class=\"hljs-literal\">true<\/span>,\n    <span class=\"hljs-attr\">relatedTarget<\/span>: event.relatedTarget,\n  }));\n  <span class=\"hljs-keyword\">this<\/span>.onFocus?.(event);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\" id=\"conclusion\">Conclusion<\/h2>\n\n\n\n<p><code>delegatesFocus<\/code> provides high impact for the right components. It eliminates manual shadow root traversal, makes host click areas intuitive, and moves <code>:focus-within<\/code> styling to CSS where it belongs.<\/p>\n\n\n\n<p>For <code>AgButton<\/code>, <code>AgInput<\/code>, and <code>&lt;ag-select&gt;<\/code>, the browser now does the work we were doing by hand. That is the right outcome.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>You don&#8217;t necessarily have to do focus handling yourself with shadow DOM web components. For simple wrapper components, there is an easier (and better) way.<\/p>\n","protected":false},"author":45,"featured_media":9088,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[49,469,3,68,36],"class_list":["post-9073","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-accessibility","tag-focus","tag-javascript","tag-shadow-dom","tag-web-components"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/delegatefocus.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9073","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/45"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=9073"}],"version-history":[{"count":9,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9073\/revisions"}],"predecessor-version":[{"id":9091,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9073\/revisions\/9091"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/9088"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=9073"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=9073"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=9073"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}