Table
A semantic, accessible data table component with striped rows, borders, sticky header, color-themed headers, and responsive horizontal scrolling. Use <bit-tr head> for header rows, <bit-tr foot> for footer rows, plain <bit-tr> for body rows, with <bit-th> and <bit-td> for cells.
Features
- 📋 Flat row API: Compose with
<bit-tr head>,<bit-tr>,<bit-tr foot>,<bit-th>,<bit-td>— no wrapper elements needed - 🌈 6 Color Themes: primary, secondary, info, success, warning, error
- 📏 3 Size Variants: sm, md, lg
- 🦓 Striped 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-busy - 📱 Responsive: horizontal scroll container prevents layout overflow
- 🏷️ Visible caption rendered above the table, also used as
aria-label - ♿ Fully Accessible: WCAG 2.1 Level AA compliant
- 🎨 CSS custom properties for complete styling control
Source Code
View Source Code
import { aria, defineComponent, effect, html, onMount } from '@vielzeug/craftit';
import type { ComponentSize, ThemeColor } from '../../types';
import { colorThemeMixin, reducedMotionMixin } from '../../styles';
import componentStyles from './table.css?inline';
/* ── Types ───────────────────────────────────────────────────────────────── */
/** Table component properties */
export type BitTableProps = {
/** Show borders between rows and around the table */
bordered?: boolean;
/** Visible caption text — also used as accessible label for the table group */
caption?: string;
/** Theme color applied to the header row background */
color?: ThemeColor;
/** Show a loading / busy state */
loading?: boolean;
/** Component size: 'sm' | 'md' | 'lg' */
size?: ComponentSize;
/** Stick the header row to the top while the body scrolls */
sticky?: boolean;
/** Alternating row stripe background */
striped?: boolean;
};
/* ── Sub-components (no shadow DOM) ─────────────────────────────────────── */
// bit-tr, bit-th, bit-td are lightweight markers in the light DOM.
// bit-table reads them and builds a fully-native shadow <table> so that
// browser features that only work on real table elements (colspan/rowspan,
// position:sticky on <th>, table layout algorithm) all work correctly.
if (!customElements.get('bit-tr')) customElements.define('bit-tr', class extends HTMLElement {});
if (!customElements.get('bit-th')) customElements.define('bit-th', class extends HTMLElement {});
if (!customElements.get('bit-td')) customElements.define('bit-td', class extends HTMLElement {});
export const TR_TAG = 'bit-tr';
export const TH_TAG = 'bit-th';
export const TD_TAG = 'bit-td';
/* ── Table proxy helpers ─────────────────────────────────────────────────── */
// Attributes on bit-th / bit-td that should be forwarded to the native cell.
const CELL_ATTRS = ['colspan', 'rowspan', 'scope', 'headers', 'abbr', 'axis', 'align', 'valign', 'width'];
/**
* Build (or rebuild) the entire native shadow table from the current light-DOM
* bit-tr / bit-th / bit-td structure. Returns a cleanup function that
* disconnects all MutationObservers created during the build.
*/
function buildTable(
host: HTMLElement,
thead: HTMLTableSectionElement,
tbody: HTMLTableSectionElement,
tfoot: HTMLTableSectionElement,
): () => void {
const observers: MutationObserver[] = [];
// Clear all sections first
thead.textContent = '';
tbody.textContent = '';
tfoot.textContent = '';
/**
* Mirror one bit-td / bit-th → native td / th, keeping text content and
* relevant attributes in sync via a MutationObserver.
*/
function mirrorCell(source: Element, into: HTMLTableSectionElement | HTMLTableRowElement): HTMLTableCellElement {
const isHeader = source.localName === 'bit-th';
const cell = document.createElement(isHeader ? 'th' : 'td');
// Forward allowed attributes
for (const attr of CELL_ATTRS) {
const val = source.getAttribute(attr);
if (val !== null) cell.setAttribute(attr, val);
}
cell.textContent = source.textContent ?? '';
// Keep text + attrs in sync
const obs = new MutationObserver(() => {
cell.textContent = source.textContent ?? '';
for (const attr of CELL_ATTRS) {
const val = source.getAttribute(attr);
if (val !== null) cell.setAttribute(attr, val);
else cell.removeAttribute(attr);
}
});
obs.observe(source, { attributes: true, characterData: true, childList: true, subtree: true });
observers.push(obs);
into.appendChild(cell);
return cell;
}
/**
* Mirror one bit-tr → native tr with all its cells.
*/
function mirrorRow(source: Element, section: HTMLTableSectionElement): void {
const tr = document.createElement('tr');
for (const child of source.children) {
if (child.localName === 'bit-th' || child.localName === 'bit-td') {
mirrorCell(child, tr);
}
}
section.appendChild(tr);
}
// Walk all direct children of bit-table
for (const child of host.children) {
if (child.localName !== 'bit-tr') continue;
if (child.hasAttribute('head')) {
mirrorRow(child, thead);
} else if (child.hasAttribute('foot')) {
mirrorRow(child, tfoot);
} else {
mirrorRow(child, tbody);
}
}
return () => {
for (const obs of observers) obs.disconnect();
};
}
/**
* Accessible data table. Compose with `<bit-tr>`, `<bit-th>`, and `<bit-td>`.
* Add `head` to header rows and `foot` to footer rows.
*
* @element bit-table
*
* @attr {string} caption - Visible caption and accessible label
* @attr {string} color - Header theme: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {boolean} bordered - Outer border and radius
* @attr {boolean} loading - Busy / loading state
* @attr {string} size - Size variant: 'sm' | 'md' | 'lg'
* @attr {boolean} sticky - Stick header cells during vertical scroll
* @attr {boolean} striped - Alternating row backgrounds
*
* @part scroll - Horizontally-scrollable container
* @part table - The native `<table>` element
* @part head - The native `<thead>` element
* @part body - The native `<tbody>` element
* @part foot - The native `<tfoot>` element
*
* @cssprop --table-bg - Table background
* @cssprop --table-border - Full border shorthand for row separators (e.g. `2px solid red`)
* @cssprop --table-header-bg - Header row background
* @cssprop --table-header-color - Header cell text color
* @cssprop --table-cell-padding - Cell padding (e.g. `0.75rem 1rem`)
* @cssprop --table-cell-font-size - Cell font size
* @cssprop --table-cell-color - Body cell text color
* @cssprop --table-stripe-bg - Stripe row background
* @cssprop --table-row-hover-bg - Row hover background
* @cssprop --table-radius - Outer corner radius
* @cssprop --table-shadow - Outer box shadow
* @cssprop --table-sticky-max-height - Max height when `sticky` is active (default `24rem`)
*
* @example
* ```html
* <bit-table caption="Members" striped bordered color="primary">
* <bit-tr head>
* <bit-th scope="col">Name</bit-th>
* <bit-th scope="col">Role</bit-th>
* </bit-tr>
* <bit-tr><bit-td>Alice</bit-td><bit-td>Admin</bit-td></bit-tr>
* <bit-tr><bit-td>Bob</bit-td><bit-td>Editor</bit-td></bit-tr>
* <bit-tr foot><bit-td colspan="2">2 members</bit-td></bit-tr>
* </bit-table>
* ```
*/
export const TABLE_TAG = defineComponent<BitTableProps>({
props: {
bordered: { default: false, type: Boolean },
caption: { default: undefined },
color: { default: undefined },
loading: { default: false, type: Boolean },
size: { default: undefined },
sticky: { default: false, type: Boolean },
striped: { default: false, type: Boolean },
},
setup({ host, props }) {
aria({
busy: () => props.loading.value,
label: () => props.caption.value ?? null,
});
// Build the fully-native shadow table via DOM APIs (not innerHTML) to avoid
// HTML-parser foster-parenting which would eject <slot> elements from table
// contexts. All three issues — color themes, sticky headers, colspan —
// require real <thead>/<tbody>/<tfoot>/<tr>/<th>/<td> in the shadow tree.
onMount(() => {
const scrollContainer = host.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');
// Keep part assignment imperative so template typing stays strict.
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);
// Sync caption text from prop
effect(() => {
captionEl.hidden = !(captionEl.textContent = props.caption.value ?? '');
});
// Initial build
let cleanupCellObservers = buildTable(host, thead, tbody, tfoot);
// Rebuild whenever direct children change (rows added / removed / reordered)
const structureObserver = new MutationObserver(() => {
cleanupCellObservers();
cleanupCellObservers = buildTable(host, thead, tbody, tfoot);
});
structureObserver.observe(host, { childList: true });
return () => {
structureObserver.disconnect();
cleanupCellObservers();
};
});
return html`<div class="scroll-container"></div>`;
},
styles: [colorThemeMixin, reducedMotionMixin, componentStyles],
tag: 'bit-table',
});Basic Usage
<bit-table caption="Team Members">
<bit-tr head>
<bit-th scope="col">Name</bit-th>
<bit-th scope="col">Role</bit-th>
<bit-th scope="col">Status</bit-th>
</bit-tr>
<bit-tr>
<bit-td>Alice</bit-td>
<bit-td>Admin</bit-td>
<bit-td>Active</bit-td>
</bit-tr>
<bit-tr>
<bit-td>Bob</bit-td>
<bit-td>Editor</bit-td>
<bit-td>Active</bit-td>
</bit-tr>
<bit-tr>
<bit-td>Carol</bit-td>
<bit-td>Viewer</bit-td>
<bit-td>Inactive</bit-td>
</bit-tr>
</bit-table>
<script type="module">
import '@vielzeug/buildit/table';
</script>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.
Color Themes
Apply a color attribute to tint the header row background with a semantic theme color.
Size Variants
Control cell padding and font size with the size attribute.
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 |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Color theme applied to the header row |
size | 'sm' | 'md' | 'lg' | — | Cell padding and font size |
striped | boolean | false | Alternating row background |
bordered | boolean | false | Outer border and rounded corners |
sticky | boolean | false | Stick the header row to the top while body scrolls |
loading | boolean | false | Dims the table and sets aria-busy="true" |
Slots
| Slot | Description |
|---|---|
head | A <bit-tr head> element containing header rows (<bit-tr>, <bit-th>) |
| (default) | One or more `` elements containing body rows |
foot | A <bit-tr foot> element containing footer rows |
Sub-Components
| Element | Description |
|---|---|
<bit-tr head> | Table head section — slot assigned automatically, no attribute needed |
| `` | Table body section — goes in the default slot |
<bit-tr foot> | Table foot section — slot assigned automatically, no attribute needed |
<bit-tr head> | Header row — slotted into <thead> automatically |
<bit-tr> | Body row — default slot |
<bit-tr foot> | Footer row — slotted into <tfoot> automatically |
<bit-th> | Header cell — supports scope="col" or scope="row" |
<bit-td> | Data cell — supports standard HTML attributes like colspan |
Parts
| Part | Description |
|---|---|
root | Outermost wrapper element |
caption | Visible caption element rendered above the table |
scroll | Inner scroll container wrapping the slotted content |
table | Native <table> element in shadow DOM |
head | Native <thead> element in shadow DOM |
body | Native <tbody> element in shadow DOM |
foot | Native <tfoot> element in shadow DOM |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--table-bg | — | Table background color |
--table-border | 1px solid var(--color-contrast-300) | Full border shorthand for row separators |
--table-header-bg | var(--color-contrast-100) | Header row background |
--table-header-color | var(--color-contrast-600) | Header cell text color |
--table-cell-padding | var(--size-3) var(--size-4) | Cell padding shorthand |
--table-cell-font-size | var(--text-sm) | Cell font size |
--table-cell-color | var(--color-contrast-900) | Body cell text color |
--table-stripe-bg | var(--color-contrast-100) | Alternating row background (striped) |
--table-row-hover-bg | var(--color-contrast-200) | Row background on hover |
--table-radius | var(--rounded-lg) | Outer corner radius (bordered variant) |
--table-shadow | — | Outer box shadow |
--table-sticky-max-height | 24rem | Max height when sticky is active |
Customization
Accessibility
The table component follows WCAG 2.1 Level AA standards.
bit-table
✅ Screen Readers
aria-busyis set to"true"whenloadingis active.aria-labelis set to thecaptionvalue when provided.- The native
<table>,<thead>,<tbody>, and<tfoot>elements are owned bybit-table's shadow DOM, preserving all table semantics for assistive technologies.
✅ Semantic Structure
- Use
scope="col"on<bit-th>elements for proper column-header association. - Use
scope="row"on row-header<bit-th>elements when applicable. - Use the
captionattribute onbit-tableto label the table for assistive technologies.
✅ Keyboard Navigation
- Standard browser table keyboard navigation applies (Tab, arrow keys with screen readers).
Best Practices
- Always use
<bit-th scope="col">for column headers to establish proper associations. - Use the
captionattribute onbit-tableto label every data table. - Prefer
stripedfor tables with many rows and few columns. - Set
stickyonly when the table has enough rows to require scrolling. - Use
loadingto indicate async data fetching instead of hiding the table. - Prefer
size="sm"for dense dashboard views over a separatecompactattribute. - Avoid placing interactive elements (buttons, inputs) in table cells without ensuring keyboard accessibility of those elements.