Skip to content

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
ts
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

html
<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.

PreviewCode
RTL

Bordered

The bordered attribute adds an outer border and radius around the whole table.

PreviewCode
RTL

Color Themes

Apply a color attribute to tint the header row background with a semantic theme color.

PreviewCode
RTL

Size Variants

Control cell padding and font size with the size attribute.

PreviewCode
RTL

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).

PreviewCode
RTL

Loading State

The loading attribute dims the table and sets aria-busy="true" while data is being fetched.

PreviewCode
RTL

Caption

The caption attribute renders a visible label above the table and also serves as the accessible aria-label.

PreviewCode
RTL

Combining Options

Mix attributes for a fully styled, accessible table.

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
captionstringVisible 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
stripedbooleanfalseAlternating row background
borderedbooleanfalseOuter border and rounded corners
stickybooleanfalseStick the header row to the top while body scrolls
loadingbooleanfalseDims the table and sets aria-busy="true"

Slots

SlotDescription
headA <bit-tr head> element containing header rows (<bit-tr>, <bit-th>)
(default)One or more `` elements containing body rows
footA <bit-tr foot> element containing footer rows

Sub-Components

ElementDescription
<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

PartDescription
rootOutermost wrapper element
captionVisible caption element rendered above the table
scrollInner scroll container wrapping the slotted content
tableNative <table> element in shadow DOM
headNative <thead> element in shadow DOM
bodyNative <tbody> element in shadow DOM
footNative <tfoot> element in shadow DOM

CSS Custom Properties

PropertyDefaultDescription
--table-bgTable background color
--table-border1px solid var(--color-contrast-300)Full border shorthand for row separators
--table-header-bgvar(--color-contrast-100)Header row background
--table-header-colorvar(--color-contrast-600)Header cell text color
--table-cell-paddingvar(--size-3) var(--size-4)Cell padding shorthand
--table-cell-font-sizevar(--text-sm)Cell font size
--table-cell-colorvar(--color-contrast-900)Body cell text color
--table-stripe-bgvar(--color-contrast-100)Alternating row background (striped)
--table-row-hover-bgvar(--color-contrast-200)Row background on hover
--table-radiusvar(--rounded-lg)Outer corner radius (bordered variant)
--table-shadowOuter box shadow
--table-sticky-max-height24remMax height when sticky is active

Customization

PreviewCode
RTL

Accessibility

The table component follows WCAG 2.1 Level AA standards.

bit-table

Screen Readers

  • aria-busy is set to "true" when loading is active.
  • aria-label is set to the caption value when provided.
  • The native <table>, <thead>, <tbody>, and <tfoot> elements are owned by bit-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 caption attribute on bit-table to label the table for assistive technologies.

Keyboard Navigation

  • Standard browser table keyboard navigation applies (Tab, arrow keys with screen readers).

Best Practices

  1. Always use <bit-th scope="col"> for column headers to establish proper associations.
  2. Use the caption attribute on bit-table to label every data table.
  3. Prefer striped for tables with many rows and few columns.
  4. Set sticky only when the table has enough rows to require scrolling.
  5. Use loading to indicate async data fetching instead of hiding the table.
  6. Prefer size="sm" for dense dashboard views over a separate compact attribute.
  7. Avoid placing interactive elements (buttons, inputs) in table cells without ensuring keyboard accessibility of those elements.