Shadow DOM introduces some accessibility nuances beyond ordinary HTML. This lesson covers ARIA attributes, focus management, and semantic markup specifically in the context of Lit components.
Shadow DOM and the Accessibility Tree
Modern browsers correctly flatten Shadow DOM into the accessibility tree, meaning screen readers generally see through shadow boundaries as if the content weren't encapsulated at all — so semantic HTML, ARIA roles, and labeling work largely the same inside a Lit component's template as they would in ordinary markup.
The nuance to watch for is id-based ARIA relationships (aria-labelledby, aria-describedby, for/id label pairs): these references only resolve within the same DOM tree. An id inside your Shadow DOM cannot be referenced from an aria-describedby living in the light DOM (or a different shadow root), and vice versa.
Bind ARIA attribute values directly in the template exactly like any other attribute — there's no special Lit-specific ARIA syntax.
aria-expanded/aria-checked/aria-selected expect the literal strings "true"/"false", not a boolean ? binding.
id-based references (aria-describedby, for) must point to an id within the same shadow root.
Prefer real semantic elements (<button>, <nav>, <dialog>) over generic <div>s with ARIA roles bolted on, whenever a native element fits.
Accessibility Cheat Sheet for Lit Components
Common patterns and pitfalls specific to Shadow DOM.
Situation
Guidance
Icon-only button
aria-label describing the action
Expand/collapse toggle
aria-expanded="true"/"false" as strings, updated on toggle
Live status update
role="alert" or aria-live="polite" region
Label connected to an input
<label for>/id pair inside the SAME shadow root
Focus after an action (e.g. opening a dialog)
Move focus imperatively in updated()
Custom interactive widget (not a native element)
Correct role plus full keyboard support
Managing Focus Across Re-Renders
Because Lit's diffing generally preserves existing DOM nodes rather than recreating them, focus is often retained automatically across renders — but for content that's conditionally added (like a newly opened dialog or an error message), you need to move focus explicitly, typically inside updated().
updated(changedProperties) {
if (changedProperties.has('open') && this.open) {
this.shadowRoot.querySelector('[role="dialog"]')?.focus();
}
}
The target element needs tabindex="-1" if it isn't natively focusable, so .focus() actually works on it.
Using delegatesFocus for Focusable Custom Elements
By default, calling .focus() on a custom element itself does nothing useful unless something inside its Shadow DOM is focused directly. Passing { delegatesFocus: true } to attachShadow() (which Lit exposes via a static option) makes focusing the host element delegate focus to the first focusable element inside its Shadow DOM automatically.
class CustomInput extends LitElement {
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
render() {
return html`<input />`;
}
}
// document.querySelector('custom-input').focus(); now focuses the inner <input> automatically
A Practical Accessibility Checklist
Before shipping any interactive Lit component, verify it against a short, practical checklist covering semantics, labeling, keyboard support, and focus — the same checklist applies whether the component is simple or complex.
Use native semantic elements (button, nav, label, dialog) wherever they fit, before reaching for ARIA roles on generic elements.
Every interactive element needs an accessible name — visible text, aria-label, or a properly connected <label>.
Every interactive element must be reachable and operable via keyboard alone, not just mouse/touch.
Verify id-based ARIA references resolve correctly within the same shadow root, not across boundaries.
Test with an actual screen reader (VoiceOver, NVDA, or the browser's built-in accessibility inspector) at least once, not just automated linting.
Common Mistakes
Using aria-describedby/for to reference an id that lives in a different shadow root or the light DOM, which silently fails to connect.
Building custom interactive widgets from <div>s with click handlers but no keyboard support or appropriate ARIA role.
Setting aria-expanded/aria-checked as actual booleans instead of the literal strings "true"/"false" ARIA expects.
Forgetting to move focus explicitly when new content (a dialog, an error) appears, leaving keyboard/screen reader users disoriented.
Key Takeaways
Modern browsers flatten Shadow DOM into the accessibility tree, so most semantic HTML and ARIA usage works as expected.
id-based ARIA relationships only resolve within the same DOM tree/shadow root.
Focus often needs to be managed explicitly for conditionally-rendered content, typically inside updated().
delegatesFocus: true lets .focus() on the host element delegate to an internal focusable element automatically.
Pro Tip
Build accessibility checks into your component development habit from the very first render, not as a final pass — testing with keyboard-only navigation and a screen reader while a component is still simple is far cheaper than retrofitting accessibility into a complex one later.
You now understand accessibility considerations specific to Lit and Shadow DOM. Next, learn about handling keyboard events in depth.