Skip to main content
Light Dark System

Data Table

<cw-data-table> | CwDataTable
Since 1.2 experimental

Data tables display structured tabular data with support for sorting, row selection, and custom cell rendering.

<cw-data-table id="preview-table" caption="Team members">
  <cw-data-table-column name="name" label="Name" sortable></cw-data-table-column>
  <cw-data-table-column name="role" label="Role"></cw-data-table-column>
  <cw-data-table-column name="status" label="Status" align="center"></cw-data-table-column>
</cw-data-table>

<script>
  const table = document.getElementById('preview-table');
  table.data = [
    { name: 'Alice Nguyen', role: 'Engineering Lead', status: 'Active' },
    { name: 'Bob Okafor', role: 'Designer', status: 'Active' },
    { name: 'Carol Smith', role: 'Product Manager', status: 'On Leave' },
    { name: 'Dan Petrov', role: 'Engineer', status: 'Active' }
  ];
</script>
import CwDataTable from '@cordwainer/cw-elements/dist/react/data-table';
import CwDataTableColumn from '@cordwainer/cw-elements/dist/react/data-table-column';

const data = [
  { name: 'Alice Nguyen', role: 'Engineering Lead', status: 'Active' },
  { name: 'Bob Okafor', role: 'Designer', status: 'Active' },
  { name: 'Carol Smith', role: 'Product Manager', status: 'On Leave' },
  { name: 'Dan Petrov', role: 'Engineer', status: 'Active' }
];

const App = () => (
  <CwDataTable data={data} caption="Team members">
    <CwDataTableColumn name="name" label="Name" sortable />
    <CwDataTableColumn name="role" label="Role" />
    <CwDataTableColumn name="status" label="Status" align="center" />
  </CwDataTable>
);

Examples

Basic Usage

Pass an array of objects to the data property (this must be set via JavaScript — arrays cannot be serialized as HTML attributes). Add <cw-data-table-column> children to define which fields to display and how to label them.

<cw-data-table id="basic-table" caption="Inventory">
  <cw-data-table-column name="product" label="Product"></cw-data-table-column>
  <cw-data-table-column name="category" label="Category"></cw-data-table-column>
  <cw-data-table-column name="stock" label="Stock" align="end"></cw-data-table-column>
</cw-data-table>

<script>
  document.getElementById('basic-table').data = [
    { product: 'Widget Pro', category: 'Hardware', stock: 142 },
    { product: 'Gadget Plus', category: 'Electronics', stock: 57 },
    { product: 'Thingamajig', category: 'Hardware', stock: 0 }
  ];
</script>

Sorting

Add the sortable attribute to any column to make its header clickable. Clicking once sorts ascending; clicking again sorts descending. The table sorts client-side by default.

<cw-data-table id="sort-table" caption="Employees">
  <cw-data-table-column name="name" label="Name" sortable></cw-data-table-column>
  <cw-data-table-column name="department" label="Department" sortable></cw-data-table-column>
  <cw-data-table-column name="salary" label="Salary" align="end" sortable></cw-data-table-column>
</cw-data-table>

<script>
  document.getElementById('sort-table').data = [
    { name: 'Carol Smith', department: 'Engineering', salary: 95000 },
    { name: 'Alice Nguyen', department: 'Design', salary: 88000 },
    { name: 'Bob Okafor', department: 'Engineering', salary: 102000 },
    { name: 'Dan Petrov', department: 'Product', salary: 91000 }
  ];
</script>

Server-Side Sorting

Add server-sort to disable client-side reordering. The table will still emit cw-sort-change — listen for it and provide freshly sorted data.

Last sort:

<cw-data-table id="server-sort-table" caption="Orders" server-sort>
  <cw-data-table-column name="order" label="Order #" sortable></cw-data-table-column>
  <cw-data-table-column name="customer" label="Customer" sortable></cw-data-table-column>
  <cw-data-table-column name="total" label="Total" align="end"></cw-data-table-column>
</cw-data-table>
<p>Last sort: <span id="last-sort"></span></p>

<script>
  const serverSortTable = document.getElementById('server-sort-table');
  const lastSort = document.getElementById('last-sort');

  const allOrders = [
    { order: 'ORD-001', customer: 'Acme Corp', total: '$1,200' },
    { order: 'ORD-002', customer: 'Globex Inc', total: '$340' },
    { order: 'ORD-003', customer: 'Acme Corp', total: '$870' }
  ];

  serverSortTable.data = [...allOrders];

  serverSortTable.addEventListener('cw-sort-change', event => {
    const { column, direction } = event.detail;
    lastSort.textContent = `${column} ${direction}`;
    serverSortTable.data = [...allOrders].sort((a, b) => {
      const cmp = String(a[column]).localeCompare(String(b[column]), undefined, { numeric: true });
      return direction === 'asc' ? cmp : -cmp;
    });
  });
</script>

Single Row Selection

Set selection="single" to allow clicking a row to select it. Click the same row again to deselect. Always set row-id to the field that uniquely identifies each row.

Selected: None

<cw-data-table id="single-select-table" caption="Users" selection="single" row-id="id">
  <cw-data-table-column name="name" label="Name"></cw-data-table-column>
  <cw-data-table-column name="email" label="Email"></cw-data-table-column>
</cw-data-table>
<p>Selected: <span id="single-select-output">None</span></p>

<script>
  const singleTable = document.getElementById('single-select-table');
  const singleOutput = document.getElementById('single-select-output');

  singleTable.data = [
    { id: 'u1', name: 'Alice Nguyen', email: 'alice@example.com' },
    { id: 'u2', name: 'Bob Okafor', email: 'bob@example.com' },
    { id: 'u3', name: 'Carol Smith', email: 'carol@example.com' }
  ];

  singleTable.addEventListener('cw-row-selection-change', event => {
    const rows = event.detail.selectedRows;
    singleOutput.textContent = rows.length ? rows[0].name : 'None';
  });
</script>

Multiple Row Selection

Set selection="multiple" to add a checkbox column. A select-all checkbox in the header selects or deselects all currently visible rows.

Selected: 0 rows

<cw-data-table id="multi-select-table" caption="Tasks" selection="multiple" row-id="id">
  <cw-data-table-column name="title" label="Task"></cw-data-table-column>
  <cw-data-table-column name="assignee" label="Assignee"></cw-data-table-column>
  <cw-data-table-column name="priority" label="Priority" align="center"></cw-data-table-column>
</cw-data-table>
<p>Selected: <span id="multi-select-output">0 rows</span></p>

<script>
  const multiTable = document.getElementById('multi-select-table');
  const multiOutput = document.getElementById('multi-select-output');

  multiTable.data = [
    { id: 't1', title: 'Design new logo', assignee: 'Alice', priority: 'High' },
    { id: 't2', title: 'Write release notes', assignee: 'Bob', priority: 'Medium' },
    { id: 't3', title: 'Fix login bug', assignee: 'Carol', priority: 'High' },
    { id: 't4', title: 'Update dependencies', assignee: 'Dan', priority: 'Low' }
  ];

  multiTable.addEventListener('cw-row-selection-change', event => {
    const count = event.detail.selectedRows.length;
    multiOutput.textContent = count === 1 ? '1 row' : `${count} rows`;
  });
</script>

Custom Cell Rendering

Set a renderCell function on any <cw-data-table-column> to control how that column’s cells are rendered. The function receives the cell value, the full row object, and the column element. Return a Lit html template result or a plain string.

<cw-data-table id="render-table" caption="Projects">
  <cw-data-table-column name="name" label="Project"></cw-data-table-column>
  <cw-data-table-column id="status-col" name="status" label="Status" align="center"></cw-data-table-column>
  <cw-data-table-column id="progress-col" name="progress" label="Progress" align="end"></cw-data-table-column>
</cw-data-table>

<script type="module">
  import { html } from '/dist/cw-elements.js';

  const table = document.getElementById('render-table');

  table.data = [
    { name: 'Alpha Launch', status: 'complete', progress: 100 },
    { name: 'Beta Testing', status: 'active', progress: 62 },
    { name: 'v2 Planning', status: 'pending', progress: 10 }
  ];

  const variantMap = { complete: 'success', active: 'primary', pending: 'neutral' };

  document.getElementById('status-col').renderCell = value =>
    html`<cw-badge variant=${variantMap[value] ?? 'neutral'}>${value}</cw-badge>`;

  document.getElementById('progress-col').renderCell = value =>
    html`<cw-progress-bar value=${value} style="min-width:80px"></cw-progress-bar>`;
</script>

Empty State

When data is an empty array and loading is not set, the table shows an empty-state message. Use empty-text to customize the message or the empty slot for richer content.

<cw-data-table caption="Search results" empty-text="No results match your search.">
  <cw-data-table-column name="name" label="Name"></cw-data-table-column>
  <cw-data-table-column name="email" label="Email"></cw-data-table-column>
</cw-data-table>

<script>
  document.querySelector('cw-data-table[caption="Search results"]').data = [];
</script>

Loading State

Set loading to show a spinner overlay while data is being fetched. The table structure remains visible underneath the overlay so layout doesn’t jump when data arrives.

<cw-data-table id="loading-table" caption="Reports" loading>
  <cw-data-table-column name="name" label="Report"></cw-data-table-column>
  <cw-data-table-column name="date" label="Date"></cw-data-table-column>
</cw-data-table>

<script>
  const loadingTable = document.getElementById('loading-table');
  loadingTable.data = [];

  setTimeout(() => {
    loadingTable.data = [
      { name: 'Q1 Summary', date: '2026-03-31' },
      { name: 'Q2 Summary', date: '2026-06-30' }
    ];
    loadingTable.loading = false;
  }, 2000);
</script>

Striped Rows

Add the striped attribute for alternating row background colors, which can improve readability for wide tables.

<cw-data-table id="striped-table" caption="Logs" striped>
  <cw-data-table-column name="time" label="Time"></cw-data-table-column>
  <cw-data-table-column name="level" label="Level" align="center"></cw-data-table-column>
  <cw-data-table-column name="message" label="Message"></cw-data-table-column>
</cw-data-table>

<script>
  document.getElementById('striped-table').data = [
    { time: '10:00:01', level: 'INFO', message: 'Server started' },
    { time: '10:00:05', level: 'INFO', message: 'Database connected' },
    { time: '10:01:12', level: 'WARN', message: 'High memory usage' },
    { time: '10:02:44', level: 'ERROR', message: 'Request timeout' },
    { time: '10:03:01', level: 'INFO', message: 'Retry succeeded' }
  ];
</script>

Bordered

Add the bordered attribute to show vertical dividers between columns.

<cw-data-table id="bordered-table" caption="Comparison" bordered>
  <cw-data-table-column name="feature" label="Feature"></cw-data-table-column>
  <cw-data-table-column name="basic" label="Basic" align="center"></cw-data-table-column>
  <cw-data-table-column name="pro" label="Pro" align="center"></cw-data-table-column>
  <cw-data-table-column name="enterprise" label="Enterprise" align="center"></cw-data-table-column>
</cw-data-table>

<script>
  document.getElementById('bordered-table').data = [
    { feature: 'Users', basic: '5', pro: '50', enterprise: 'Unlimited' },
    { feature: 'Storage', basic: '10 GB', pro: '100 GB', enterprise: '1 TB' },
    { feature: 'API access', basic: '—', pro: '✓', enterprise: '✓' },
    { feature: 'SSO', basic: '—', pro: '—', enterprise: '✓' }
  ];
</script>

Pairing with Pagination

<cw-data-table> does not manage pagination itself. Use <cw-pagination> alongside it and slice your data to the current page.

<cw-data-table id="paged-table" caption="Records"></cw-data-table-column>
  <cw-data-table-column name="id" label="#" align="end"></cw-data-table-column>
  <cw-data-table-column name="name" label="Name"></cw-data-table-column>
  <cw-data-table-column name="city" label="City"></cw-data-table-column>
</cw-data-table>
<cw-pagination id="paged-pagination" total-items="20" page-size="5" page="1" style="margin-top: var(--cw-spacing-medium)"></cw-pagination>

<script>
  const allRows = Array.from({ length: 20 }, (_, i) => ({
    id: i + 1,
    name: `Person ${i + 1}`,
    city: ['New York', 'London', 'Tokyo', 'Paris'][i % 4]
  }));

  const pagedTable = document.getElementById('paged-table');
  const pagination = document.getElementById('paged-pagination');

  function renderPage(page) {
    const start = (page - 1) * 5;
    pagedTable.data = allRows.slice(start, start + 5);
  }

  renderPage(1);

  pagination.addEventListener('cw-change', () => renderPage(pagination.page));
</script>

Hidden Columns

Set hidden on a <cw-data-table-column> to hide it without removing it from the DOM. This is useful for toggling column visibility dynamically.

Show Email Show Phone
<div style="display:flex; gap: var(--cw-spacing-small); margin-bottom: var(--cw-spacing-small)">
  <cw-checkbox id="toggle-email" checked>Show Email</cw-checkbox>
  <cw-checkbox id="toggle-phone" checked>Show Phone</cw-checkbox>
</div>
<cw-data-table id="hidden-col-table" caption="Contacts">
  <cw-data-table-column name="name" label="Name"></cw-data-table-column>
  <cw-data-table-column id="email-col" name="email" label="Email"></cw-data-table-column>
  <cw-data-table-column id="phone-col" name="phone" label="Phone"></cw-data-table-column>
</cw-data-table>

<script>
  document.getElementById('hidden-col-table').data = [
    { name: 'Alice', email: 'alice@example.com', phone: '555-0100' },
    { name: 'Bob', email: 'bob@example.com', phone: '555-0101' }
  ];

  document.getElementById('toggle-email').addEventListener('cw-change', e => {
    document.getElementById('email-col').hidden = !e.target.checked;
  });

  document.getElementById('toggle-phone').addEventListener('cw-change', e => {
    document.getElementById('phone-col').hidden = !e.target.checked;
  });
</script>

Roadmap

<cw-data-table> ships as an MVP covering the most common use cases. The following enhancements are planned for future releases:

  • Row size / density — a size attribute (compact / comfortable) to control row height and cell padding, following the same convention as other components.

  • Select-all-data vs. select-all-visible — the current select-all checkbox operates on the rows present in data (the visible page). A future enhancement will expose an indeterminate select-all state and a “Select all N records” affordance for paginated server-side datasets, similar to Gmail’s bulk-selection pattern. Consumers can approximate this today by handling cw-row-selection-change and programmatically setting selectedRows.

  • Column pinning — a pinned="start" or pinned="end" attribute on <cw-data-table-column> to freeze columns via position: sticky, keeping key fields visible when scrolling wide tables horizontally.

  • Column resizing — drag handles on column header borders to let users resize individual columns.

  • Column reordering — drag-and-drop column header reordering.

  • Rich header slot — a label slot on <cw-data-table-column> for placing icons or tooltips inside column headers, complementing the current label attribute.

  • Row grouping — group rows by a shared field value under a collapsible group header row.

  • Expandable rows — a toggle on each row to reveal a detail panel (nested content, description, related records).

  • Editable cells — inline editing via a <cw-input> or <cw-select> rendered inside a cell on click, emitting a cw-cell-change event.

  • Virtualized rendering — render only the rows visible in the viewport for very large datasets. Scheduled for v1.4 alongside virtual scrolling for <cw-select> and <cw-tree>.

Importing

If you’re using the autoloader or the traditional loader, you can ignore this section. Otherwise, feel free to use any of the following snippets to cherry pick this component.

Script Import Bundler React

To import this component from the CDN using a script tag:

<script type="module" src="https://cdn.jsdelivr.net/npm/@cordwainer/cw-elements@1.2.3/cdn/components/data-table/data-table.js"></script>

To import this component from the CDN using a JavaScript import:

import 'https://cdn.jsdelivr.net/npm/@cordwainer/cw-elements@1.2.3/cdn/components/data-table/data-table.js';

To import this component using a bundler:

import '@cordwainer/cw-elements/dist/components/data-table/data-table.js';

To import this component as a React component:

import CwDataTable from '@cordwainer/cw-elements/dist/react/data-table';

Slots

Name Description
(default) Place <cw-data-table-column> elements here to define columns.
empty Content shown when data is empty and not loading.

Learn more about using slots.

Properties

Name Description Reflects Type Default
data The array of row data objects to display. This is a property only and cannot be set as an attribute. Each object’s keys correspond to column name values. Record[] []
rowId
row-id
The key in each row object used as a stable row identity for selection. Falls back to row index when not set. string ''
selection Controls row selection behavior. 'none' | 'single' | 'multiple' 'none'
sortColumn
sort-column
The column currently used for sorting, identified by its name. string ''
sortDirection
sort-direction
The direction of the current sort. 'asc' | 'desc' 'asc'
serverSort
server-sort
Disables client-side sorting. When set, the component emits cw-sort-change but does not reorder data itself — the consumer is responsible for fetching/providing sorted data. boolean false
loading Shows a loading spinner overlay over the table body. boolean false
striped Applies alternating row background colors. boolean false
bordered Adds borders between cells. boolean false
caption An accessible caption for the table, rendered as a visually hidden <caption>. string ''
emptyText
empty-text
Text shown in the empty state when data is empty and loading is false. string 'No data found'
selectedRows Returns the currently selected row objects. Set this property to programmatically control selection. Record[] -
updateComplete A read-only promise that resolves when the component has finished updating.

Learn more about attributes and properties.

Events

Name React Event Description Event Detail
cw-sort-change onCwSortChange Emitted when the user clicks a sortable column header. detail: { column: string, direction: 'asc' | 'desc' }. SortChangeEvent
cw-row-selection-change onCwRowSelectionChange Emitted when row selection changes. detail: { selectedRows: object[] }. CwRowSelectionChangeEvent
cw-row-click onCwRowClick Emitted when a body row is clicked. detail: { row: object, index: number }. RowClickEvent

Learn more about events.

Custom Properties

Name Description Default
--cw-data-table-border-color Border color used when bordered is set. Default: var(--cw-color-neutral-200).
--cw-data-table-stripe-color Alternate row background color when striped is set. Default: var(--cw-color-neutral-50).
--cw-data-table-row-hover-color Row background on hover. Default: var(--cw-color-neutral-100).
--cw-data-table-selected-row-color Selected row background. Default: var(--cw-color-primary-50).
--cw-data-table-header-background Header row background. Default: var(--cw-color-neutral-50).

Learn more about customizing CSS custom properties.

Parts

Name Description
base The outer scrollable wrapper.
table The <table> element.
header-row The <tr> in <thead>.
header-cell Each <th> element.
sort-button The clickable button inside a sortable <th>.
sort-icon The sort direction icon inside a sortable <th>.
body-row Each <tr> in <tbody>.
body-row--selected Added to body rows that are selected.
body-cell Each <td> element.
checkbox-cell The <th> or <td> that holds the selection checkbox.
loading The loading overlay shown when loading is true.
empty The empty-state container shown when data is empty.

Learn more about customizing CSS parts.

Dependencies

This component automatically imports the following dependencies.

  • <cw-checkbox>
  • <cw-icon>
  • <cw-spinner>