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.
captionnames the table without cross-root references.- Sort header pattern — a
buttoninsideth,aria-sorton theth. - 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).
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.
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 |
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"- Press T and confirm the caption is the table’s name.
- Navigate cells with table commands; confirm headers and row headers.
- Tab to a sortable header; press Enter; confirm aria-sort and the status message.
- Use the component from two frameworks; confirm identical behaviour.
- 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"- Render the whole table in one shadow root.
- Provide
label(caption) and optionaldescriptionproperties. - Use header buttons with aria-sort on th.
- Include an internal status region with an opt-out.
- Expose
::partand custom properties for focus and selection styles. - 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.
Related
Permalink to "Related"- Slotted content and table semantics — why not slot rows
- Accessible data tables in Svelte — a compiled alternative
- Sortable & filterable data grids — the sorting pattern