The previous lesson used automated tests to check that <task-card> does what's expected of it: it shows the right title, changes badge according to status, expands on click. But all those checks implicitly assume that whoever uses TaskFlow does so with a mouse and a conventional screen. This lesson revisits that assumption: what happens to a component's accessibility when its content lives inside a Shadow DOM, which ARIA roles and attributes are needed for <task-card> and <task-filter> to make sense to someone navigating with a keyboard or a screen reader, and how to manage focus when an interaction visually changes a component's content.
Contents
- Shadow DOM and accessibility: what changes and what doesn't
- The real limit: ARIA relationships by
iddon't cross the shadow root - Roles and ARIA attributes on a custom element
aria-live: announcing dynamic updates- Managing focus inside a component
delegatesFocus: delegating focus to the first focusable element- Auditing
<task-card>: from clickable<article>to accessible control - Auditing
<task-filter>: labels and button state - Verifying with the module's tests
- Shadow DOM and accessibility: what changes and what doesn't
Before getting into the specific problems, it's worth clearing up a fairly widespread misconception: Shadow DOM does not break accessibility on its own. A screen reader, when traversing the page, walks the full accessibility tree, including content living inside any shadow root, exactly as a browser paints that same content on screen without the user perceiving any visual boundary. An <h3> inside <task-card>'s shadow root is announced as a level-3 heading, just as if it were written directly in the main document; a <button> inside a shadow root remains a focusable, keyboard-actionable button, with its semantic role intact.
What does change, and is the real source of most accessibility problems specific to Web Components, are the ARIA mechanisms that rely on id references between two elements.
- The real limit: ARIA relationships by
id don't cross the shadow root
id don't cross the shadow rootSeveral ARIA attributes, in the general web accessibility standard, work by pointing at another element's id: aria-labelledby="id-del-titulo", aria-describedby="id-de-la-descripcion", aria-controls="id-del-panel". All of them assume that the element with that id lives in the same id tree as the element referencing it, and this is where Shadow DOM introduces a real limit: ids are not global across shadow root boundaries. An aria-labelledby written inside a component's shadow root cannot point to an id that lives in the main document, outside that shadow root, and vice versa: an attribute written in the light DOM, outside any component, cannot point to an id that only exists inside that component's shadow root.
<!-- This does NOT work: the id lives inside a different shadow root --> <task-card aria-labelledby="titulo-externo"></task-card> <h2 id="titulo-externo">Tareas pendientes</h2>
This example doesn't throw any visible error: the browser simply finds no match for aria-labelledby="titulo-externo" inside the accessibility tree that corresponds to <task-card>, and the attribute is, in practice, left without effect. The solution, in the vast majority of cases, is to resolve the relationship inside the component's own shadow root, not across its boundaries: if <task-card> needs an aria-labelledby, the id it points to must also be inside its own render(), never outside.
| Situation | Does it work? |
|---|---|
aria-labelledby points to an id inside the same shadow root |
Yes |
aria-labelledby points to an id in the main document, from inside a shadow root |
No |
aria-labelledby points to an id in a different shadow root |
No |
An ARIA attribute without an id reference (aria-label, aria-expanded, role) |
Yes, with no boundary limitation |
This table explains, in passing, why the rest of this lesson relies mostly on ARIA attributes that don't depend on an id (aria-label, aria-expanded, aria-pressed, aria-live, role): they're the ones that work predictably without needing to reason about shadow root boundaries at all.
- Roles and ARIA attributes on a custom element
A custom element, like <task-card> or <task-filter>, has no implicit accessibility role simply by being registered with customElements.define: unlike <button> or <input>, which the browser recognizes natively with their semantics already built in, a custom element behaves, by default, like a generic <div> for accessibility purposes, unless its own render() includes elements with native semantics (like <task-card>'s <select>, which does provide its own "combobox" role without needing anything extra) or is explicitly given ARIA attributes.
The role attribute declares what type of control an element is for accessibility purposes, when its HTML tag doesn't make that clear on its own:
render() {
return html`
<article role="button" tabindex="0" aria-expanded="${this.expandida}">
<!-- ...content... -->
</article>
`;
}role="button" tells any assistive technology that this <article>, even though it's not a native <button>, should be treated as one: announced as an actionable control, not as a plain block of text. aria-label, an alternative to or complement of visible text, provides an accessible label when a control's visual content isn't enough or doesn't exist (for example, a button that only shows an icon, with no text a screen reader can read directly).
render() {
return html`
<button aria-label="Eliminar tarea" @click="${this.notificarEliminacion}">
đź—‘
</button>
`;
}Without aria-label, a screen reader would announce this button simply as "wastebasket" or, worse, as a Unicode character with no clear meaning, depending on how it interprets the emoji; with aria-label="Eliminar tarea", the announced text is explicit and describes the action, regardless of which icon is used visually.
aria-live: announcing dynamic updates
aria-live: announcing dynamic updatesThe attributes seen so far describe the nature of a control at a given moment, but TaskFlow has had, since module 6, content that changes without any direct user interaction: the "⏰ Está a punto de vencer" warning that <task-card> shows when ContadorTiempoRestanteController detects a task is approaching its deadline. A user looking at the screen notices that change immediately; someone using a screen reader, who only learns about what's happening the moment they decide to revisit that specific part of the page, might never notice it if nothing announces it actively.
aria-live solves exactly this problem: it marks a region of the document as a live region, whose content, when it changes, is automatically announced by the screen reader without the user having to navigate back to it.
render() {
return html`
<article>
<!-- ...rest of the card... -->
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
</article>
`;
}The "polite" value (as opposed to "assertive", the other common option) indicates that the announcement should wait for the screen reader to finish any other reading in progress before interrupting with the new content, rather than immediately cutting off whatever is being read at that instant. For an informational notice like this one —important, but not critical or urgent in the sense of requiring an immediate reaction— "polite" is almost always the right choice; "assertive" is reserved for warnings that truly can't wait (a serious error, a session about to expire), and using it indiscriminately tends to be more disruptive than useful.
- Managing focus inside a component
Keyboard focus —which specific element receives the next keystrokes— is another aspect that a component with Shadow DOM manages exactly like any other DOM element: the standard elemento.focus() method, inherited from HTMLElement, works with no difference on a node inside a shadow root, and document.activeElement, from outside, points to the custom element itself that holds the focus (not directly to the focused internal node, which is accessible via elementoPersonalizado.shadowRoot.activeElement).
A typical case in TaskFlow: when expanding <task-card> to show its detail, it makes sense to move focus toward the newly appeared content, so that someone navigating with a keyboard doesn't get "lost" on an element that no longer occupies the same visual spot.
willUpdate(changedProperties) {
// (existing willUpdate code for fechaLimite, unchanged)
}
updated(changedProperties) {
if (changedProperties.has('expandida') && this.expandida) {
this.shadowRoot.querySelector('.detalle')?.focus();
}
}This snippet takes advantage of updated, the hook introduced in module 6 to react to changes already reflected in the DOM: only when expandida changes and its new value is true (that is, exactly when the card goes from collapsed to expanded), it looks for the freshly rendered .detalle block and requests focus on it. For a <div> like .detalle to be able to receive focus via .focus(), it also needs a tabindex attribute (tabindex="-1" is the usual choice when an element needs to be focusable via code, but should not be part of the normal keyboard tab sequence).
delegatesFocus: delegating focus to the first focusable element
delegatesFocus: delegating focus to the first focusable elementThere's a different, simpler situation worth distinguishing from the one in the previous section: when the custom element itself, as a whole, should behave as if it were focusable, delegating that focus to the first focusable element inside it. Lit offers this through a shadow root option, declared as a static class property:
class TaskFilter extends LitElement {
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
// ...rest of the class unchanged...
}With delegatesFocus: true, if someone calls document.querySelector('task-filter').focus(), or if <task-filter> receives focus while tabbing to it, the browser automatically delegates that focus to the first focusable element inside its shadow root —in <task-filter>'s case, the search <input>— without the component itself needing to write any extra logic to achieve it. It's a particularly useful option for components that ultimately wrap a single main interactive control (like a text field or a button), where it makes sense for the custom element itself to "be," for focus purposes, that internal control.
- Auditing
<task-card>: from clickable <article> to accessible control
<task-card>: from clickable <article> to accessible controlWith all the theory now covered, it's time to apply it to <task-card>'s <article>, which since module 3 has been listening for @click to toggle expandida with no accessibility support whatsoever: no semantic role, no indication of its expanded or collapsed state, and no way to activate it from the keyboard (an <article> is not, by itself, a focusable element nor one actionable with the Enter or Space key).
render() {
return html`
<article
role="button"
tabindex="0"
aria-expanded="${this.expandida}"
@click="${this.alternarExpandida}"
@keydown="${this._gestionarTeclaExpandir}"
>
<div class="cabecera">
${this.renderAvatar()}
<h3>${this.titulo}</h3>
</div>
${this.renderInsigniaEstado()}
${this.renderSelectorEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.urgente && html`<p class="aviso">âš Urgente</p>`}
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
${this.expandida
? html`<div class="detalle" tabindex="-1"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
_gestionarTeclaExpandir(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.alternarExpandida();
}
}Four changes, each solving a specific problem from the previous sections: role="button" announces the <article> as an actionable control, not a plain block of content; tabindex="0" adds it to the normal keyboard tab sequence (without this attribute, an <article> would never receive focus, no matter what role it's given); aria-expanded="${this.expandida}" reflects, at all times, whether the card is expanded or collapsed, the same kind of information a native <details> would communicate on its own; and @keydown="${this._gestionarTeclaExpandir}", together with its handler, makes the Enter and Space keys trigger the same action as the click, exactly the behavior any native <button> would offer out of the box and that a role="button" on a non-native element has to reproduce manually. event.preventDefault() in the case of the Space key also prevents the browser from scrolling the page down, its default behavior for that key on a focused element.
- Auditing
<task-filter>: labels and button state
<task-filter>: labels and button state<task-filter>, since the "Shared Context with @lit/context" lesson, has an <input> with no accessible label attached (only a placeholder, which doesn't serve the same purpose: it disappears as soon as the user types, and many screen readers don't even announce it consistently) and three buttons whose "active" state is only communicated visually, via the activo CSS class managed with classMap.
render() {
const { texto, estado } = this.valorActual;
return html`
<div class="filtro" role="search">
<input
type="text"
aria-label="Buscar tarea por título"
placeholder="Buscar tarea…"
.value="${texto}"
@input="${this.manejarTexto}"
/>
<div class="filtro__botones" role="group" aria-label="Filtrar por estado">
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button
class="${classMap({ activo: estado === opcion })}"
aria-pressed="${estado === opcion}"
@click="${() => this.manejarEstado(opcion)}"
>
${{ todas: 'Todas', pendiente: 'Pendientes', hecha: 'Hechas' }[opcion]}
</button>
`
)}
</div>
</div>
`;
}aria-label="Buscar tarea por tĂtulo" on the <input> covers exactly the gap noted earlier: a stable accessible label that doesn't depend on the field being empty in order to be read. role="search" on the overall container identifies the whole region as a search area, an ARIA convention some screen readers use to offer direct navigation to that part of the page. role="group" together with aria-label="Filtrar por estado" groups the three buttons under a common label, so a screen reader announces the set as "Filtrar por estado, group," instead of three loose buttons with no apparent relation between them.
The most important change, however, is aria-pressed="${estado === opcion}": it's the ARIA equivalent, for a "toggle"-type button, of what classMap({ activo: ... }) was already doing purely visually. Without this attribute, someone navigating with a screen reader can see (or, in this case, hear) each button's name, but has no way of knowing which of the three is currently selected; aria-pressed solves exactly that gap, communicating the same state that the activo class communicates visually, without the two mechanisms ever conflicting with each other (in fact, they follow exactly the same condition, estado === opcion, so they can never get out of sync).
- Verifying with the module's tests
This module's changes aren't only checked by ear with a screen reader (although that manual check remains irreplaceable before considering any accessibility improvement finished); they can also be verified with the same tool from the previous lesson, extending the existing tests:
it('expone role="button" y aria-expanded en el article', async () => {
const el = await fixture(html`<task-card></task-card>`);
const articulo = el.shadowRoot.querySelector('article');
expect(articulo.getAttribute('role')).to.equal('button');
expect(articulo.getAttribute('aria-expanded')).to.equal('false');
articulo.click();
await el.updateComplete;
expect(articulo.getAttribute('aria-expanded')).to.equal('true');
});This test checks, without needing an actual screen reader, that the accessibility contract holds: the correct role is present from the start, and aria-expanded faithfully reflects the internal expandida state before and after the interaction, exactly the same "simulate interaction, wait for updateComplete, check the result" pattern already practiced in the previous lesson.
Common Mistakes and Tips
- Using
aria-labelledbyoraria-describedbypointing to anidoutside the component's shadow root: as explained in section 2, that relationship simply doesn't work; the referencedidmust live inside the same shadow tree as the attribute using it. - Adding
role="button"withouttabindexor keyboard handling: arolealone only changes what a screen reader announces, not the element's actual behavior; withouttabindex="0"and akeydownhandler for Enter and Space, as seen in section 7, the control remains inaccessible to someone navigating exclusively with a keyboard, even though it may "sound" correct to someone using a screen reader with a mouse. - Using
aria-live="assertive"for any dynamic update, "just in case": as explained in section 4, indiscriminate use ofassertiveconstantly interrupts ongoing reading, ending up more annoying than useful;"polite"is the right choice for the vast majority of informational updates, including<task-card>'s in this module. - Duplicating visual state and accessible state without both deriving from the same source: if
aria-pressedwere computed with a different condition than the one feedingclassMap({ activo: ... }), the two could drift out of sync after some future change to either; as noted in section 8, keeping them derived from the same expression (estado === opcion) eliminates that risk at the root.
Exercises
- Add to
<task-filter>anaria-live="polite"on a new paragraph showing how many tasks match the current filter (for example, "3 tareas encontradas"), so that a screen reader user learns the filter's result without having to navigate manually to the list. - Explain, based on section 2, why it wouldn't be correct for
<task-list>to try writingaria-labelledbyon each<task-card>pointing to an<h2>living in<task-list>'s own shadow root, and which alternative (from those seen in section 3) would solve the same problem of giving each card an accessible label related to the list containing it. - A teammate proposes removing
tabindex="0"from the<article>in section 7, arguing thatrole="button"should already be enough for the element to be focusable. Explain why that assumption is incorrect, drawing on the explanation in section 7 itself.
Solutions
render() {
return html`
<div class="filtro" role="search">
<!-- ...input and buttons unchanged... -->
<p aria-live="polite">${this.tareasFiltradas?.length ?? 0} tareas encontradas</p>
</div>
`;
}(Note: since tareasFiltradas lives in <task-list>, not <task-filter>, a complete version of this exercise would need to expose that count as part of the filter context value itself, or through a second read-only context published by <task-list>; what matters for this exercise is the mechanics of aria-live on the result paragraph, not the exact path by which the number reaches <task-filter>.)
- An
aria-labelledbywritten inside a<task-card>'s shadow root cannot point to anidliving inside<task-list>'s shadow root, exactly due to the limitation explained in section 2:ids don't cross shadow root boundaries, in either direction. The correct alternative is to usearia-labeldirectly on each<task-card>(for example,aria-label="Tarea: ${tarea.titulo}", computed inside<task-card>'s ownrender(), with no reference to an externalid), which doesn't depend on any relationship between different shadow trees. role="button"only informs assistive technologies of what type of control the element is for accessibility semantics purposes; it does not change the underlying element's actual behavior regarding keyboard focus at all. An<article>, unlike a native<button>, is not by default part of the browser's tab sequence and does not accept keyboard focus, no matter whatroleis assigned to it; onlytabindex="0"actually adds it to that sequence. Removing it would leave an element that "sounds" like a button to a screen reader that has already found it some other way, but that could never be reached by tabbing with the keyboard, nor activated with Enter or Space without prior focus.
Conclusion
This lesson has shown that Shadow DOM, by itself, does not harm a component's accessibility, but it does impose a specific, real limit —ARIA relationships based on id don't cross its boundaries— that's worth knowing so as not to unknowingly rely on them. With role, aria-expanded, aria-pressed, and aria-live, plus explicit keyboard focus management, <task-card> and <task-filter> are now accessible to someone navigating exclusively with a keyboard or a screen reader, not just to someone using a mouse on a conventional screen.
With accessibility now covered, one last cross-cutting quality angle remains before closing the module: performance. The next lesson revisits several practices already hinted at in earlier modules —repeat with a key, willUpdate for derived calculations, avoiding unnecessary work in render()— and applies them with explicit judgment to <task-list> and <task-board> against a task list much larger than usual.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
