Building an Accessible Data Table in Lit

Permalink to "Building an Accessible Data Table in Lit"

Web components promise framework independence: one data table usable in React, Angular, Vue or plain HTML. Lit makes them pleasant to write. For accessibility, the safest design for a table component is simple: render a native table inside one shadow root, so table semantics, header associations and screen reader table commands all work as they do in plain HTML. Add sortable header buttons, a caption from a label property and a status region inside the component, and you have a portable, accessible table.

This guide belongs to web components & framework-agnostic grids.

Spec reference

Permalink to "Spec reference"
  • Native table semantics inside shadow DOM work like light DOM tables.
  • caption names the table without cross-root references.
  • Sort header pattern — a button inside th, aria-sort on the th.
  • Status region — role="status" inside the shadow root, present from first render.
  • Relevant criteria: SC 1.3.1, 4.1.2 (A), SC 4.1.3 (AA).
Structure of the Lit table component Hierarchy: the custom element host, its shadow root containing a table with caption, header row with sortable header buttons, body rows with row headers, and a status region after the table. Structure of the Lit table component<invoice-table label="Invoices">host elementshadow rootall relationships stay insidetablenative semanticscaptionfrom the label propertythead > th[aria-sort] > buttonsortable headerstbody > tr > th[scope=row]row headersp[role=status]sort and filter messages
Everything that references anything lives in one shadow root.

When to use — and when not to

Permalink to "When to use — and when not to"

Build a Lit table when you need one accessible table across several frameworks, or in a design system consumed by multiple stacks. Keep it a table; build a separate grid component if you need cell-level keyboard navigation.

Do not split the table into nested custom elements for rows and cells; table structure across shadow roots breaks header associations. The misapplication: <lit-table> containing <lit-row> elements each with its own shadow root containing tds — the browser no longer sees one table.

Nested shadow roots versus one shadow root Comparison: rows and cells as separate custom elements with their own shadow roots break table structure; one component rendering the whole table in a single shadow root keeps semantics. Nested shadow roots versus one shadow root✗ Row and cell components with shadowrootsNo single table in the accessibility treeHeader associations brokenTable navigation failsFlexible composition✓ One component, one shadow rootOne native tableHeaders associate normallyTable navigation worksComposition via data and slots

Annotated code example

Permalink to "Annotated code example"
import { LitElement, html, css } from 'lit';

export class InvoiceTable extends LitElement {
  static properties = { label: {}, rows: { attribute: false }, columns: { attribute: false }, sort: { state: true }, status: { state: true } };
  constructor() { super(); this.rows = []; this.columns = []; this.sort = null; this.status = ''; }

  #toggle(col) {
    const dir = this.sort?.key === col.key && this.sort.dir === 'ascending' ? 'descending' : 'ascending';
    this.sort = { key: col.key, dir };
    this.status = `Sorted by ${col.label}, ${dir}`;                        // SC 4.1.3
    this.dispatchEvent(new CustomEvent('sort-change', { detail: this.sort, bubbles: true, composed: true }));
  }

  render() {
    const sorted = sortRows(this.rows, this.sort);
    return html`
      <table>
        <caption>${this.label}</caption>                                   <!-- name without cross-root refs -->
        <thead><tr>${this.columns.map(c => html`
          <th scope="col" aria-sort=${this.sort?.key === c.key ? this.sort.dir : 'none'}>
            ${c.sortable ? html`<button type="button" @click=${() => this.#toggle(c)}>${c.label}</button>` : c.label}
          </th>`)}</tr></thead>
        <tbody>${sorted.map(r => html`<tr>
          <th scope="row">${r[this.columns[0].key]}</th>
          ${this.columns.slice(1).map(c => html`<td>${r[c.key]}</td>`)}
        </tr>`)}</tbody>
      </table>
      <p role="status" class="vh">${this.status}</p>`;                    // inside the same root
  }
  static styles = css`.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} th button:focus-visible{outline:3px solid var(--focus, #155c38)}`;
}
customElements.define('invoice-table', InvoiceTable);
<!-- Usage in any framework or plain HTML -->
<invoice-table label="Invoices"></invoice-table>
<script>document.querySelector('invoice-table').columns = cols; document.querySelector('invoice-table').rows = data;</script>

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Interaction Expected Announcement
T (table navigation) Reaches the table “Invoices, table, 41 rows, 4 columns”
Table cell navigation Headers with cells Native behaviour
Tab to header button Focus Header name; sort state on th
Enter Sort “Sorted by Amount, ascending”
Framework re-render with new rows Table updates Optional count message from host app
Using the component from frameworks Matrix of frameworks — React, Vue, Angular, plain HTML — against passing rows, listening to events and setting the label. Using the component from frameworksPass rowsListen to sort-changeSet labelReact 19PropertyonSortChange-style eventsAttributeReact 18Ref and propertyaddEventListenerAttributeVue 3.prop binding@sort-changeAttributeAngular[rows] property(sort-change)AttributePlain HTMLPropertyaddEventListenerAttribute
Properties and events work everywhere; React needs care with complex properties in older versions.

Integration context

Permalink to "Integration context"

Why one shadow root matters is explained in slotted content and table semantics and Shadow DOM and cross-root ARIA references. The sort pattern is the same as in aria-sort attributes for accessible column filtering. A Svelte equivalent is in accessible data tables in Svelte.

Gotchas

Permalink to "Gotchas"

Status region inside the component. If the host app also announces (for example, filter counts), coordinate so messages do not collide; expose a property to disable internal announcements.

Styling across the shadow boundary. Expose CSS custom properties and ::part() for focus and header styles so themes can keep focus visible.

Large datasets. Rendering thousands of rows in a table is slow in any framework; paginate or virtualize, and keep row positions conveyed.

Worked example

Permalink to "Worked example"

A payments company needed one transactions table for a React dashboard, an Angular back office and a server-rendered statements page. A Lit <txn-table> rendering a native table in one shadow root, with a label property for the caption, sortable header buttons, an internal status region (disabled in the React app, which has its own announcer) and ::part(focus-ring) for theming, served all three. Accessibility tests run once against the component and again in each host app’s smoke suite.

Testing it with a screen reader

Permalink to "Testing it with a screen reader"
  1. Press T and confirm the caption is the table’s name.
  2. Navigate cells with table commands; confirm headers and row headers.
  3. Tab to a sortable header; press Enter; confirm aria-sort and the status message.
  4. Use the component from two frameworks; confirm identical behaviour.
  5. Check the accessibility tree for a single table with correct structure.

Common mistakes

Permalink to "Common mistakes"
  • Rows and cells as separate shadow-rooted components.
  • aria-labelledby pointing at page headings.
  • Sort state only as an icon.
  • No way to theme focus styles.

Adoption checklist for teams

Permalink to "Adoption checklist for teams"
  1. Render the whole table in one shadow root.
  2. Provide label (caption) and optional description properties.
  3. Use header buttons with aria-sort on th.
  4. Include an internal status region with an opt-out.
  5. Expose ::part and custom properties for focus and selection styles.
  6. Publish framework usage examples and test them.

FAQ

Permalink to "FAQ"
Are tables inside shadow DOM accessible?

Yes. A native table inside one shadow root behaves like any other table.

How do I name the table?

With a caption rendered from a label property, avoiding cross-root references.

Can I use slots for rows?

Slotting tr elements into a table inside shadow DOM does not produce valid table structure. Pass data instead; see the slots guide.

Should the component announce sorting?

Yes, internally, with an option to defer to the host app’s announcer.

How do I support row selection?

Add checkboxes with per-row labels and expose selection via properties and events.

Does Lit add anything accessibility-specific?

Lit renders what you write; the accessibility comes from the markup and patterns you choose.

How do I test across frameworks?

Mount the component in small React, Vue and Angular test apps and run the same Playwright checks.

How do I support a selection column?

Render checkboxes in the first column inside the shadow table, name them with the row header text, and expose a selected property and a selection-change event.

Can the component be server-rendered?

With Lit SSR and declarative shadow DOM, yes; the initial HTML then contains the full table and caption.

Permalink to "Related"

← Back to Web Components & Framework-Agnostic Grids