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

  1. Shadow DOM and accessibility: what changes and what doesn't
  2. The real limit: ARIA relationships by id don't cross the shadow root
  3. Roles and ARIA attributes on a custom element
  4. aria-live: announcing dynamic updates
  5. Managing focus inside a component
  6. delegatesFocus: delegating focus to the first focusable element
  7. Auditing <task-card>: from clickable <article> to accessible control
  8. Auditing <task-filter>: labels and button state
  9. Verifying with the module's tests

  1. 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.

  1. The real limit: ARIA relationships by id don't cross the shadow root

Several 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.

  1. 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.

  1. aria-live: announcing dynamic updates

The 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.

  1. 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).

  1. delegatesFocus: delegating focus to the first focusable element

There'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.

  1. Auditing <task-card>: from clickable <article> to accessible control

With 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.

  1. Auditing <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).

  1. 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-labelledby or aria-describedby pointing to an id outside the component's shadow root: as explained in section 2, that relationship simply doesn't work; the referenced id must live inside the same shadow tree as the attribute using it.
  • Adding role="button" without tabindex or keyboard handling: a role alone only changes what a screen reader announces, not the element's actual behavior; without tabindex="0" and a keydown handler 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 of assertive constantly 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-pressed were computed with a different condition than the one feeding classMap({ 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

  1. Add to <task-filter> an aria-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.
  2. Explain, based on section 2, why it wouldn't be correct for <task-list> to try writing aria-labelledby on 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.
  3. A teammate proposes removing tabindex="0" from the <article> in section 7, arguing that role="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>.)

  1. An aria-labelledby written inside a <task-card>'s shadow root cannot point to an id living 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 use aria-label directly on each <task-card> (for example, aria-label="Tarea: ${tarea.titulo}", computed inside <task-card>'s own render(), with no reference to an external id), which doesn't depend on any relationship between different shadow trees.
  2. 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 what role is assigned to it; only tabindex="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

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved