Table
A semantic, accessible data table component with striped rows, borders, sticky header, and responsive horizontal scrolling. Use <sg-tr head> for header rows, <sg-tr foot> for footer rows, plain <sg-tr> for body rows, with <sg-th> and <sg-td> for cells.
Features
Flat row API: Compose with <sg-tr head>,<sg-tr>,<sg-tr foot>,<sg-th>,<sg-td>— no wrapper elements needed3 Density Variants: compact·cozy(default) ·comfortableStriped rows for easier scanning of dense data Bordered variant with rounded outline Sticky header that stays visible while the body scrolls Loading / busy state with reduced opacity and aria-busyResponsive: horizontal scroll container prevents layout overflow Visible caption rendered above the table, also used as aria-labelFully Accessible: WCAG 2.1 Level AA compliant CSS custom properties for complete styling control
Source Code
View Source Code
import { define, html, prop } from '@vielzeug/craft';
import { reducedMotionMixin, tableBaseMixin } from '../../styles';
import componentStyles from './table.css?inline';
/* ── Types ───────────────────────────────────────────────────────────────── */
/** Table component properties */
export type SgTableProps = {
/** Adds a thicker outer border */
bordered?: boolean;
/** Visible caption text — also used as the accessible table label via `aria-label` */
caption?: string;
/** Cell density: `'compact'` | `'cozy'` (default) | `'comfortable'` */
density?: 'compact' | 'cozy' | 'comfortable';
/** Expands the table to 100% of its container width */
fullwidth?: boolean;
/** Applies a busy/disabled state with reduced opacity */
loading?: boolean;
/** Enables sticky column headers with a vertical scroll container */
sticky?: boolean;
/** Alternating row stripe backgrounds */
striped?: boolean;
};
/* ── Child element markers ───────────────────────────────────────────────── */
// sg-tr, sg-th, sg-td are lightweight light-DOM markers.
// sg-table reads them and constructs a fully-native shadow <table> so that
// browser features that require real table elements (colspan/rowspan,
// position:sticky on <thead>, table layout algorithm) all work correctly.
// Attributes on sg-th/sg-td are mirrored to the generated native cells.
/**
* Light-DOM row marker consumed by `<sg-table>`.
*
* @element sg-tr
* @attr {boolean} head - Places the row in the generated `<thead>` section
* @attr {boolean} foot - Places the row in the generated `<tfoot>` section
*
* @example
* ```html
* <sg-table>
* <sg-tr head><sg-th>Name</sg-th><sg-th>Role</sg-th></sg-tr>
* <sg-tr><sg-td>Alice</sg-td><sg-td>Admin</sg-td></sg-tr>
* </sg-table>
* ```
*/
if (!customElements.get('sg-tr')) customElements.define('sg-tr', class extends HTMLElement {});
/**
* Light-DOM header cell marker consumed by `<sg-table>`.
*
* @element sg-th
* @attr {number} colspan - Mirrors to native `<th colspan>`
* @attr {number} rowspan - Mirrors to native `<th rowspan>`
* @attr {string} scope - Mirrors to native `<th scope>`: 'col' | 'row' | 'colgroup' | 'rowgroup'
* @attr {string} headers - Mirrors to native `<th headers>`
*
* @example
* ```html
* <sg-tr head>
* <sg-th scope="col">Name</sg-th>
* <sg-th scope="col" colspan="2">Address</sg-th>
* </sg-tr>
* ```
*/
if (!customElements.get('sg-th')) customElements.define('sg-th', class extends HTMLElement {});
/**
* Light-DOM data cell marker consumed by `<sg-table>`.
*
* @element sg-td
* @attr {number} colspan - Mirrors to native `<td colspan>`
* @attr {number} rowspan - Mirrors to native `<td rowspan>`
* @attr {string} headers - Mirrors to native `<td headers>`
*
* @example
* ```html
* <sg-tr>
* <sg-td>Alice</sg-td>
* <sg-td colspan="2">123 Main St, Springfield</sg-td>
* </sg-tr>
* ```
*/
if (!customElements.get('sg-td')) customElements.define('sg-td', class extends HTMLElement {});
/* ── Proxy/mirror helpers ────────────────────────────────────────────────── */
// Attributes forwarded from sg-th/sg-td to the generated native cell.
// scope is intentionally excluded — it requires fallback logic and is handled separately.
const CELL_ATTRS = ['colspan', 'rowspan', 'headers', 'abbr'];
/**
* Sync text content and tracked attributes from a light-DOM marker to its
* native mirror. `fallbackScope` is applied when the source element carries no
* explicit `scope` attribute, ensuring the auto-inferred value is always
* present and is restored if an explicit override is later removed.
*/
function syncCell(source: Element, native: HTMLTableCellElement, fallbackScope?: string): void {
if (source.childElementCount > 0) {
// Source has element children — deep-clone them into the native cell so
// components like sg-skeleton render correctly inside the shadow table.
// Guard: skip re-clone if the serialised content is identical to avoid
// redundant DOM work on every MutationObserver tick (e.g. 250+ cells
// of skeleton loaders all cloning on each attribute change).
const snapshot = source.innerHTML;
if (native.getAttribute('data-src-html') === snapshot) return;
native.setAttribute('data-src-html', snapshot);
native.textContent = '';
for (const child of source.childNodes) {
native.appendChild(child.cloneNode(true));
}
} else {
const text = source.textContent ?? '';
if (native.textContent !== text) native.textContent = text;
}
for (const attr of CELL_ATTRS) {
const val = source.getAttribute(attr);
if (val !== null) native.setAttribute(attr, val);
else native.removeAttribute(attr);
}
// scope: explicit attribute wins; otherwise restore the inferred fallback.
const explicitScope = source.getAttribute('scope');
if (explicitScope !== null) native.setAttribute('scope', explicitScope);
else if (fallbackScope) native.scope = fallbackScope;
else native.removeAttribute('scope');
}
/** Entry stored in the cell map: native mirror element + its inferred scope fallback. */
type CellEntry = { inferredScope: string | undefined; native: HTMLTableCellElement };
/**
* (Re)build the entire native shadow table from the current light-DOM markers.
* Returns a WeakMap of source marker → CellEntry for targeted sync by the
* content observer. Storing `inferredScope` per cell ensures that removing an
* explicit `scope` attribute reverts to the auto-inferred value rather than
* dropping it entirely.
*/
function buildTable(
host: HTMLElement,
thead: HTMLTableSectionElement,
tbody: HTMLTableSectionElement,
tfoot: HTMLTableSectionElement,
): WeakMap<Element, CellEntry> {
const cellMap = new WeakMap<Element, CellEntry>();
thead.textContent = '';
tbody.textContent = '';
tfoot.textContent = '';
for (const child of host.children) {
if (child.localName !== 'sg-tr') continue;
const section = child.hasAttribute('head') ? thead : child.hasAttribute('foot') ? tfoot : tbody;
const tr = document.createElement('tr');
for (const cell of child.children) {
if (cell.localName !== 'sg-th' && cell.localName !== 'sg-td') continue;
const isHeader = cell.localName === 'sg-th';
const native = document.createElement(isHeader ? 'th' : 'td');
// Auto-infer scope for <th> elements; undefined for <td>.
const inferredScope = isHeader ? (section === thead ? 'col' : 'row') : undefined;
native.setAttribute('part', section === tbody ? 'cell' : 'header-cell');
native.removeAttribute('data-src-html');
syncCell(cell, native, inferredScope);
cellMap.set(cell, { inferredScope, native });
tr.appendChild(native);
}
section.appendChild(tr);
}
return cellMap;
}
/**
* Data table component.
*
* Reads light-DOM `<sg-tr>`/`<sg-th>`/`<sg-td>` markers and projects them
* into a fully-native shadow `<table>`. Cell attributes (`colspan`, `rowspan`,
* `scope`, etc.) are mirrored. Changes are observed and synced incrementally.
*
* Native table features — sticky headers, colspan/rowspan, the table layout
* algorithm — all work because the shadow tree contains real table elements.
*
* @element sg-table
*
* @attr {boolean} bordered - Thicker outer border
* @attr {string} caption - Caption text shown above the table
* @attr {boolean} fullwidth - Expands to 100% container width
* @attr {boolean} loading - Busy state: reduced opacity, no pointer events
* @attr {string} density - Cell density: 'compact' | 'cozy' | 'comfortable' (default: 'cozy')
* @attr {boolean} sticky - Sticky `<thead>` with scroll container
* @attr {boolean} striped - Alternating row backgrounds
*
* @part scroll - Scroll container that hosts the generated native table
* @part table - Generated native `<table>` element
* @part head - Generated native `<thead>` section
* @part body - Generated native `<tbody>` section
* @part foot - Generated native `<tfoot>` section
* @part cell - Every native `<td>` in `<tbody>` rows
* @part header-cell - Every native `<th>` / `<td>` in `<thead>` and `<tfoot>` rows
*
* @cssprop --table-bg - Table background color
* @cssprop --table-border-color - Cell separator and outer border color
* @cssprop --table-radius - Corner radius of the table container
* @cssprop --table-shadow - Box shadow of the table container
* @cssprop --table-header-bg - Background of header and footer rows
* @cssprop --table-accent - Accent color for interactive states
* @cssprop --table-row-hover-bg - Row hover background
* @cssprop --table-stripe-bg - Even-row stripe background
* @cssprop --table-cell-padding-x - Cell horizontal padding
* @cssprop --table-cell-padding-y - Cell vertical padding
* @cssprop --table-font-size - Base font size for cells
* @cssprop --table-sticky-max-height - Max height of the sticky scroll container
* @cssprop --table-sticky-header-bg - Background of sticky header cells
* @cssprop --table-sticky-blur - Backdrop blur applied to sticky headers
*
* @example
* ```html
* <sg-table caption="Top repositories" striped bordered sticky>
* <sg-tr head>
* <sg-th>Repository</sg-th>
* <sg-th>Stars</sg-th>
* </sg-tr>
* <sg-tr>
* <sg-td>vielzeug/sigil</sg-td>
* <sg-td>1 200</sg-td>
* </sg-tr>
* </sg-table>
* ```
*/
export const TABLE_TAG = 'sg-table' as const;
define<SgTableProps>(TABLE_TAG, {
props: {
bordered: prop.bool(false),
caption: prop.string(),
density: prop.string<'compact' | 'cozy' | 'comfortable'>(),
fullwidth: prop.bool(false),
loading: prop.bool(false),
sticky: prop.bool(false),
striped: prop.bool(false),
},
setup(props, { bind, el, onMounted, watch }) {
bind({
attr: {
'aria-busy': props.loading,
'aria-label': props.caption,
},
});
// Build the native shadow table via DOM APIs (not innerHTML) to avoid
// HTML-parser foster-parenting, which ejects table section elements from
// their intended positions in the tree.
onMounted(() => {
const scrollContainer = el.shadowRoot!.querySelector('.scroll-container')!;
const table = document.createElement('table');
const captionEl = document.createElement('caption');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
const tfoot = document.createElement('tfoot');
scrollContainer.setAttribute('part', 'scroll');
table.setAttribute('part', 'table');
thead.setAttribute('part', 'head');
tbody.setAttribute('part', 'body');
tfoot.setAttribute('part', 'foot');
table.append(captionEl, thead, tbody, tfoot);
scrollContainer.appendChild(table);
// Reactively sync caption text and visibility from prop.
watch(() => {
const text = props.caption.value ?? '';
captionEl.textContent = text;
captionEl.hidden = text === '';
});
// Initial full build.
let cellMap = buildTable(el, thead, tbody, tfoot);
// Content observer: syncs text/attribute changes inside sg-th/sg-td.
// Remains connected throughout the component lifetime — no
// disconnect/reconnect during structural rebuilds. Records that arrive
// for cells no longer in cellMap (after a rebuild) are silently ignored
// by the `if (entry)` guard, so there is no correctness risk.
const contentObserver = new MutationObserver((records) => {
for (const rec of records) {
const sourceCell = (rec.target instanceof Element ? rec.target : rec.target.parentElement)?.closest(
'sg-th, sg-td',
);
if (sourceCell) {
const entry = cellMap.get(sourceCell);
if (entry) syncCell(sourceCell, entry.native, entry.inferredScope);
}
}
});
contentObserver.observe(el, {
// Include 'scope' in attributeFilter so explicit scope changes are picked
// up and the fallback-restore logic in syncCell runs correctly.
attributeFilter: [...CELL_ATTRS, 'scope'],
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
// Structure observer: triggers a full rebuild when sg-tr elements are
// added, removed, or reordered. Scoped to direct children only so it
// never fires for cell-level mutations.
const structureObserver = new MutationObserver(() => {
cellMap = buildTable(el, thead, tbody, tfoot);
});
structureObserver.observe(el, { childList: true });
return () => {
structureObserver.disconnect();
contentObserver.disconnect();
};
});
return html`<div class="scroll-container"></div>`;
},
styles: [reducedMotionMixin, tableBaseMixin('table'), componentStyles],
});Basic Usage
<sg-table caption="Team Members">
<sg-tr head>
<sg-th>Name</sg-th>
<sg-th>Role</sg-th>
<sg-th>Status</sg-th>
</sg-tr>
<sg-tr>
<sg-td>Alice</sg-td>
<sg-td>Admin</sg-td>
<sg-td>Active</sg-td>
</sg-tr>
<sg-tr>
<sg-td>Bob</sg-td>
<sg-td>Editor</sg-td>
<sg-td>Active</sg-td>
</sg-tr>
<sg-tr>
<sg-td>Carol</sg-td>
<sg-td>Viewer</sg-td>
<sg-td>Inactive</sg-td>
</sg-tr>
</sg-table>Visual Options
Striped Rows
The striped attribute applies alternating row backgrounds, making it easier to track across wide tables.
Bordered
The bordered attribute adds an outer border and radius around the whole table.
Fullwidth
The fullwidth attribute expands the table to fill 100% of its container width.
Density
Control cell padding with the density attribute. Font size is unaffected.
Sticky Header
Set sticky to keep the header row visible when the table body scrolls. Set --table-sticky-max-height to control the scroll viewport height (default 24rem).
Loading State
The loading attribute dims the table and sets aria-busy="true" while data is being fetched.
Caption
The caption attribute renders a visible label above the table and also serves as the accessible aria-label.
Combining Options
Mix attributes for a fully styled, accessible table.
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
caption | string | — | Visible caption rendered above the table |
density | 'compact' | 'cozy' | 'comfortable' | 'cozy' | Cell padding; compact = tight, cozy = default, comfortable = spacious |
striped | boolean | false | Alternating row background |
bordered | boolean | false | Outer border and rounded corners |
fullwidth | boolean | false | Expands the table to 100% of its container width |
sticky | boolean | false | Stick the header row to the top while body scrolls |
loading | boolean | false | Dims the table and sets aria-busy="true" |
Child Elements
sg-table reads light-DOM marker elements and projects them into a native shadow <table>. There are no slots — child elements are observed via MutationObserver.
| Element | Description |
|---|---|
<sg-tr head> | Header row — projected into <thead> |
<sg-tr> | Body row — projected into <tbody> |
<sg-tr foot> | Footer row — projected into <tfoot> |
<sg-th> | Header cell — mirrored as native <th>; scope is auto-inferred if omitted |
<sg-td> | Data cell — mirrored as native <td>; supports colspan, rowspan, etc. |
Mirrored Attributes
Attributes on <sg-th> and <sg-td> are forwarded to the generated native <th>/<td> in the shadow tree.
| Attribute | Elements | Description |
|---|---|---|
colspan | sg-th, sg-td | Spans multiple columns |
rowspan | sg-th, sg-td | Spans multiple rows |
scope | sg-th | Column/row association — auto-inferred if absent |
headers | sg-th, sg-td | Associates cell with header IDs |
Parts
| Part | Description |
|---|---|
scroll | Overflow container — target for max-height / scrolling |
table | Generated native <table> element |
head | Generated native <thead> element |
body | Generated native <tbody> element |
foot | Generated native <tfoot> element |
cell | Every native <td> in <tbody> rows |
header-cell | Every native <th> / <td> in <thead> and <tfoot> rows |
CSS Custom Properties
| Property | Description |
|---|---|
--table-bg | Table background color |
--table-border-color | Cell separator and outer border color |
--table-radius | Corner radius (applied when bordered) |
--table-shadow | Box shadow on the host element |
--table-header-bg | Background of <thead> and <tfoot> |
--table-accent | Accent color used for hover states |
--table-row-hover-bg | Row background on hover |
--table-stripe-bg | Even-row background when striped |
--table-cell-padding-x | Horizontal cell padding |
--table-cell-padding-y | Vertical cell padding |
--table-font-size | Base font size for all cells |
--table-sticky-max-height | Max height of the scroll container when sticky |
--table-sticky-header-bg | Background of the sticky <thead> (with blur) |
--table-sticky-blur | Backdrop blur intensity on the sticky header |
Customization
Accessibility
The table component follows WCAG 2.1 Level AA standards.
sg-table
aria-busyis set to"true"whenloadingis active.aria-labelis set to thecaptionvalue when provided.- The native
<table>,<thead>,<tbody>, and<tfoot>elements are owned bysg-table's shadow DOM, preserving all table semantics for assistive technologies.
<sg-th>in a<sg-tr head>row automatically getsscope="col"on the native<th>.<sg-th>in a body row automatically getsscope="row". Provide an explicitscopeattribute to override.- Use the
captionattribute onsg-tableto label the table for assistive technologies.
- Standard browser table keyboard navigation applies (Tab, arrow keys with screen readers).
Best Practices
- Always use
<sg-th>(not<sg-td>) for header cells —scopeis inferred automatically. - Use the
captionattribute on every data table to give it an accessible label. - Prefer
stripedfor tables with many rows and few columns to aid row tracking. - Set
stickyonly when the table has enough rows to require scrolling; pair with--table-sticky-max-height. - Use
loadingto indicate async data fetching instead of hiding or removing the table. - Use
density="compact"for dense dashboard tables;density="comfortable"when more breathing room is needed. The defaultdensity="cozy"suits most cases. - Avoid placing interactive elements (buttons, inputs) inside cells without ensuring their keyboard accessibility.