Slotted Content and Table Semantics

Permalink to "Slotted Content and Table Semantics"

Slots are how web components accept content: the page writes <span slot="caption">…</span> and the component places it in its shadow DOM. They are invaluable for captions, toolbars and empty states. They are dangerous for table structure. A table’s rows, header cells and data cells must be in the right parent-child relationships for the browser to build table semantics, and slotting tr or td elements across a shadow boundary does not produce a valid table: the browser’s table model works on the actual DOM tree, not the flattened composed tree, in ways that differ between engines. The result is a table that looks right and is announced as fragments.

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

Spec reference

Permalink to "Spec reference"
  • HTML table model — determined by the DOM tree; tr must be a child of table, thead, tbody or tfoot; slotted elements remain children of the host in the light DOM.
  • Flattened tree — used for rendering and accessibility in many respects, but table structure built from slotted rows is unreliable across browsers.
  • SC 1.3.1 Info and Relationships (A) — header-cell relationships must be programmatic.
Slotting rows versus passing data Comparison: slotted tr elements remain light DOM children of the host and break the table model; data passed as properties renders native rows inside the shadow root with correct structure. Slotting rows versus passing data✗ Slot <tr> elements into a tableRows are children of the host, not the tableHeader association unreliableTable navigation partial or brokenLooks fine visually✓ Pass rows as dataRows rendered inside the tableHeaders associate normallyTable navigation worksWorks for everyone

When to use — and when not to

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

Slot content that sits around or inside cells but is not structural: captions (inside a caption element rendered by the component), toolbars, filter controls, empty-state messages, help text, and rich content inside a cell’s container. Pass rows and columns as data.

If page authors must control row markup (for example, server-rendered rows), consider a light-DOM component that enhances an existing table instead of a shadow DOM component that rebuilds it.

The misapplication: <fancy-table><tr slot="row">…</tr></fancy-table>, which renders a visually correct table with broken semantics.

What to slot and what to pass as data Matrix of content — caption text, toolbar, empty state, rows, header cells, cell content, row actions — against slot or data and the reason. What to slot and what to pass as dataSlot or dataReasonCaption textSlot into captionpage owns the wordingToolbar and filtersSlotoutside the table structureEmpty stateSlotpage owns the messageRowsDatastructure must be inside the tableHeader cellsDataheader associationCell content (rich)Template or render functionrendered inside the cellRow actionsTemplateneeds per-row names
Structure is data; decoration and context can be slotted.

Annotated code example

Permalink to "Annotated code example"
// Light-DOM enhancement alternative: keep the author's table, add behaviour
class SortableTable extends HTMLElement {
  connectedCallback() {
    const table = this.querySelector('table');                     // author's native table stays intact
    table.querySelectorAll('th[data-sort]').forEach((th) => {
      const btn = document.createElement('button');
      btn.type = 'button'; btn.textContent = th.textContent; th.textContent = ''; th.append(btn);
      th.setAttribute('aria-sort', 'none');
      btn.addEventListener('click', () => this.#sort(table, th));
    });
    this.status = Object.assign(document.createElement('p'), { className: 'visually-hidden' });
    this.status.setAttribute('role', 'status'); this.append(this.status);
  }
  #sort(table, th) { /* reorder tbody rows; update aria-sort; set this.status.textContent */ }
}
customElements.define('sortable-table', SortableTable);
<!-- Author keeps full control of semantics; the component only adds behaviour -->
<sortable-table>
  <table>
    <caption>Invoices</caption>
    <thead><tr><th scope="col">Invoice</th><th scope="col" data-sort>Amount</th></tr></thead>
    <tbody><tr><th scope="row">INV-1042</th><td>1,240.00</td></tr></tbody>
  </table>
</sortable-table>
// Shadow DOM component: slots only for non-structural content
render() {
  return html`
    <slot name="toolbar"></slot>
    <table>
      <caption><slot name="caption">${this.label}</slot></caption>
      <thead>…</thead>
      <tbody>${this.rows.map(r => html`<tr>…</tr>`)}</tbody>       <!-- rows from data -->
    </table>
    ${this.rows.length ? '' : html`<slot name="empty">No records.</slot>`}`;
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Approach Table navigation Headers announced Notes
Rows slotted into shadow table Partial or broken Unreliable Varies by browser
Rows as data in shadow table Works Yes Recommended for shadow components
Light-DOM enhancement Works Yes Author controls markup
Slotted caption text — Table name includes it Works via flattened tree
Choosing a component architecture for tables Flow: must page authors write the rows? Use a light-DOM enhancement. Otherwise use a shadow DOM component that receives rows as data and slots only captions, toolbars and empty states. Choosing a component architecture for tablesAuthors write rows?server-rendered markuplight-DOM enhancementData-driven rows?JSON from an APIshadow component with dataSlotscaption, toolbar, empty, helpnon-structural onlyCell contenttemplates rendered inside cellsper-row namesVerifyaccessibility tree shows one tablein every browser

Integration context

Permalink to "Integration context"

A shadow DOM table built this way is shown in building an accessible data table in Lit. Naming across boundaries is covered in Shadow DOM and cross-root ARIA references. For the semantic table itself, see semantic HTML table construction.

Gotchas

Permalink to "Gotchas"

display: contents. Some components use display: contents on wrappers to make slotted elements lay out as table parts; historically this removed elements from the accessibility tree in some browsers. Avoid it on semantic elements, or test carefully.

Progressive enhancement. Light-DOM enhancement components work without JavaScript (the table is still there), which is a robustness bonus.

Styling. Light-DOM tables are styled by page CSS; shadow tables need parts and custom properties.

Worked example

Permalink to "Worked example"

A CMS-driven site used a <fancy-table> web component that accepted rows through slots, so editors could write table rows in the CMS. Visually the tables were perfect; with NVDA, header cells were not associated with data cells and table navigation reported odd row counts. The team rewrote the component as a light-DOM enhancement: editors write a normal table, and the component adds sorting buttons, a status region and sticky headers around it. Semantics are now the author’s native table, and the content works even before JavaScript loads.

Testing it with a screen reader

Permalink to "Testing it with a screen reader"
  1. Inspect the accessibility tree; confirm one table with rows, headers and cells.
  2. Use table navigation; confirm headers are announced with cells.
  3. Confirm the caption (including slotted text) names the table.
  4. Test in Chrome, Firefox and Safari; slot behaviour differs.
  5. Check empty-state slots render and are announced when results are empty.

Common mistakes

Permalink to "Common mistakes"
  • Slotting rows or cells into shadow tables.
  • Using display: contents on semantic wrappers.
  • Hiding the empty-state slot from screen readers.
  • No documentation of slot semantics.

Adoption checklist for teams

Permalink to "Adoption checklist for teams"
  1. Decide per component: light-DOM enhancement or data-driven shadow component.
  2. Never accept structural table elements through slots.
  3. Provide caption, toolbar and empty slots with documented semantics.
  4. Render cell content through templates inside the component.
  5. Add accessibility-tree tests in three browsers.

FAQ

Permalink to "FAQ"
Can I slot td elements into a shadow table?

Not reliably. The table model uses the DOM tree, and slotted elements are children of the host.

Is light-DOM enhancement an anti-pattern for web components?

No. It is a good fit when authors own the markup, and it works without JavaScript.

Does slotted caption text name the table?

Yes; slotted text inside a caption rendered by the component contributes to the name.

How do I let authors customise cells?

Accept render functions or templates that the component calls inside each cell.

What about declarative shadow DOM?

It helps with server rendering of shadow components; the table must still be complete inside the shadow root.

Are there browser bugs here?

Behaviour of slotted table parts has varied across engines; that unpredictability is the main reason to avoid it.

Can a component use both approaches?

A component can enhance a light-DOM table and add shadow-DOM chrome (toolbar) around it, as long as the table stays in the light DOM.

How do sticky headers work with light-DOM enhancement?

Apply position: sticky to th elements with page CSS or a stylesheet the component injects; semantics are unaffected.

Permalink to "Related"

← Back to Web Components & Framework-Agnostic Grids