Welcome to Vanilla Breeze
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
Progressive enhancement wrapper for HTML tables. Adds sorting, filtering, pagination, row expansion, and selection.
The <data-table> component wraps a standard HTML table to provide interactive features like column sorting, text filtering, pagination, expandable rows, row selection with bulk actions, responsive card layouts, and sticky headers/columns. All features are opt-in via data attributes and the table remains fully functional without JavaScript.
<data-table> <table data-filterable data-paginate="5"> <thead> <tr> <th data-sort="string">Name</th> <th data-sort="number">Age</th> </tr> </thead> <tbody> <tr> <td>Alice Johnson</td> <td>32</td> </tr> <tr> <td>Bob Smith</td> <td>28</td> </tr> </tbody> </table></data-table>
Wrap any HTML table with <data-table> and add data attributes to enable features. The table structure remains semantic and accessible.
<data-table> <table> <thead> <tr> <th data-sort="string">Name</th> <th data-sort="string">Email</th> <th data-sort="string">Department</th> </tr> </thead> <tbody> <tr> <td>Alice Johnson</td> <td>alice.johnson@example.com</td> <td>Engineering</td> </tr> <!-- More rows... --> </tbody> </table></data-table>
Add data-sort to any <th> element to make that column sortable. Click the column header to cycle through ascending, descending, and unsorted states.
| Value | Description |
|---|---|
string |
Alphabetical sorting (case-insensitive) |
number |
Numeric sorting (handles decimals and negatives) |
date |
Date sorting (parses common date formats) |
Use data-sort-value on cells when the display value differs from the sort value (e.g., formatted currency or dates).
<!-- Display shows formatted value, sorts by raw value --><td data-sort-value="95000">$95,000</td><td data-sort-value="2019-03-15">Mar 15, 2019</td>
Add data-filterable to the table to enable text filtering. A search input is automatically generated above the table. Rows that don't match the filter text are hidden.
<data-table> <table data-filterable> <thead> <tr> <th>Name</th> <th>Email</th> <th>Department</th> </tr> </thead> <tbody> <!-- Rows will be filtered as user types --> </tbody> </table></data-table>
Add data-paginate="n" to the table to show only n rows per page. Pagination controls are automatically generated below the table.
<data-table> <table data-paginate="5"> <thead> <tr> <th>Name</th> <th>Email</th> <th>Department</th> <th>Hire Date</th> </tr> </thead> <tbody> <!-- 12 rows, showing 5 per page --> </tbody> </table></data-table>
Create expandable rows by adding data-expandable to a row and following it with a data-expand-content row. A toggle button is automatically added to control the expansion.
<data-table> <table> <thead> <tr> <th></th> <th>Name</th> <th>Department</th> <th>Status</th> </tr> </thead> <tbody> <tr data-expandable> <td><button data-action="toggle-expand">...</button></td> <td>Alice Johnson</td> <td>Engineering</td> <td>Active</td> </tr> <tr data-expand-content hidden> <td colspan="4"> <div>Additional details here...</div> </td> </tr> </tbody> </table></data-table>
Add checkboxes to rows for selection, with a "select all" checkbox in the header and a bulk actions bar that appears when rows are selected.
<data-table> <div data-bulk-actions hidden> <span><strong data-selected-count>0</strong> selected</span> <button>Export</button> <button>Archive</button> <button class="danger">Delete</button> </div> <table> <thead> <tr> <th><input type="checkbox" data-action="select-all"/></th> <th>Name</th> <th>Email</th> <th>Department</th> </tr> </thead> <tbody> <tr data-selectable> <td><input type="checkbox" data-action="select-row"/></td> <td>Alice Johnson</td> <td>alice.johnson@example.com</td> <td>Engineering</td> </tr> <!-- More selectable rows... --> </tbody> </table></data-table>
Add data-responsive="card" to transform the table into a card layout on narrow screens. Use data-label on cells to show column headers in card mode.
<data-table> <table data-responsive="card"> <thead> <tr> <th>Name</th> <th>Email</th> <th>Department</th> <th>Status</th> </tr> </thead> <tbody> <tr> <td data-label="Name">Alice Johnson</td> <td data-label="Email">alice.johnson@example.com</td> <td data-label="Department">Engineering</td> <td data-label="Status">Active</td> </tr> </tbody> </table></data-table>
Use data-sticky to keep headers or columns visible while scrolling.
| Value | Description |
|---|---|
header |
Keeps the table header row fixed at the top |
column |
Keeps the first column fixed on horizontal scroll |
both |
Both header and first column are sticky |
Use data-sticky-column="n" to make the first n columns sticky.
<!-- Sticky header only --><table data-sticky="header">...</table> <!-- Sticky first column --><table data-sticky="column">...</table> <!-- Both sticky --><table data-sticky="both">...</table> <!-- First 2 columns sticky --><table data-sticky="column" data-sticky-column="2">...</table>
The component dispatches custom events for each interactive feature.
| Event | Detail | Description |
|---|---|---|
data-table:sort |
{ column: number, direction: "asc"|"desc"|null } |
Fired when a column is sorted. |
data-table:filter |
{ query: string, count: number } |
Fired when the filter input changes. |
data-table:page |
{ page: number } |
Fired when the page changes. |
data-table:expand |
{ row: HTMLTableRowElement, expanded: boolean } |
Fired when a row is expanded or collapsed. |
data-table:selection |
{ count: number, rows: HTMLTableRowElement[] } |
Fired when row selection changes. |
const table = document.querySelector('data-table'); // Sort eventstable.addEventListener('data-table:sort', (e) => { console.log(`Column ${e.detail.column} sorted ${e.detail.direction}`);}); // Filter eventstable.addEventListener('data-table:filter', (e) => { console.log(`Filter "${e.detail.query}" matches ${e.detail.count} rows`);}); // Page change eventstable.addEventListener('data-table:page', (e) => { console.log(`Page ${e.detail.page} of ${e.detail.page}`);}); // Row expansion eventstable.addEventListener('data-table:expand', (e) => { console.log(`Row ${e.detail.expanded ? 'expanded' : 'collapsed'}`);}); // Selection eventstable.addEventListener('data-table:selection', (e) => { console.log(`${e.detail.count} rows selected`);});
The component exposes methods for programmatic control.
| Method | Parameters | Returns | Description |
|---|---|---|---|
goToPage(n) |
n: number |
void |
Navigate to a specific page (1-indexed). |
setFilter(query) |
query: string |
void |
Programmatically set the filter text. |
refresh() |
- | void |
Re-apply sorting, filtering, and pagination. |
getSelectedRows() |
- | HTMLTableRowElement[] |
Get array of currently selected rows. |
const table = document.querySelector('data-table'); // Navigate to page 3table.goToPage(3); // Filter by departmenttable.setFilter('engineering'); // Refresh after external data changetable.refresh(); // Get selected rows for bulk operationconst selected = table.getSelectedRows();selected.forEach(row => { console.log(row.cells[1].textContent); // Log names});
For reactive frameworks pushing live data, data-table exposes .rows and .columns alongside the markup-driven path. Existing <tr> nodes whose id persists across .rows assignments are preserved — selection, expansion, inline-edit state, focus, and CSS animations survive the diff.
| Property | Type | Description |
|---|---|---|
.rows | { id, [colKey]: value, ... }[] | Setter runs a keyed diff against current <tr> children. id is the diff key; other fields populate cells per the column spec. |
.columns | { key, label, sort? }[] | Read-only: column spec derived from <thead> on upgrade. Each <th>'s data-key attribute becomes the column key (falls back to lowercased text). |
.renderRow | (row, columns) => Element | Optional custom row renderer. Default builds one <td> per column populated from row[col.key]. |
<data-table> <table data-filterable> <thead> <tr> <th data-key="name" data-sort="string">Name</th> <th data-key="email" data-sort="string">Email</th> <th data-key="dept" data-sort="string">Department</th> </tr> </thead> <tbody><!-- empty; populated via .rows --></tbody> </table></data-table>
const table = document.querySelector('data-table'); await new Promise(r => table.addEventListener('data-table:upgraded', r, { once: true })); table.rows = [ { id: 'u1', name: 'Alice', email: 'a@x.io', dept: 'Engineering' }, { id: 'u2', name: 'Bob', email: 'b@x.io', dept: 'Sales' },]; // Subsequent assignments diff: same ids keep their tr nodes (selection survives).effect(() => { table.rows = visibleUsers.get(); }); // Tag custom rendering when the default cell layout isn't enough.table.renderRow = (row, cols) => { const tr = document.createElement('tr'); tr.innerHTML = cols.map(c => `<td>${c.key === 'email' ? `<a href="mailto:${row.email}">${row.email}</a>` : (row[c.key] ?? '')}</td>` ).join(''); return tr;};
| Event | Detail | When |
|---|---|---|
data-table:upgraded | — | Once after first connect; safe signal for the first .rows assignment. |
data-table:rows-changed | { rows, added, moved, removed, source: 'api' } | Fires after every .rows assignment with diff stats. |
See the Data API concepts guide for the full dual-mode contract.
Set data-weight="N" on each criterion column header, data-rollup="weighted-sum" on a Score column, and data-heatmap for visual scanning. The component computes the score per row from the weighted criteria, sets data-sort-value so the Score column remains sortable, and tints cells low/mid/high based on relative value within the column.
<data-table> <table> <thead> <tr> <th data-sort="string">Option</th> <th data-sort="number" data-weight="3" data-heatmap="high-good">Performance (×3)</th> <th data-sort="number" data-weight="2" data-heatmap="high-good">Maintainability (×2)</th> <th data-sort="number" data-weight="1" data-heatmap="high-good">Ecosystem (×1)</th> <th data-sort="number" data-rollup="weighted-sum" data-heatmap="high-good">Score</th> </tr> </thead> <tbody> <tr><td>Library A</td><td>5</td><td>4</td><td>3</td><td></td></tr> <tr><td>Library B</td><td>3</td><td>5</td><td>5</td><td></td></tr> </tbody> </table></data-table>
This recipe replaces a dedicated decision-matrix component — there's no new element needed; the rule is "behaviour on existing element → data-* attribute".
sum — sum of all sibling numeric cells.weighted-sum — each sibling cell × the data-weight on its column header.product — multiply all sibling numeric cells (e.g. likelihood × impact for risk severity).avg — average of all sibling numeric cells.max — largest sibling numeric value.high-good (also auto) — large values tinted green, small values red.low-good — inverted; small values green (e.g. risk severity, defect count).| Attribute | Values | Default | Description |
|---|---|---|---|
data-filterable | boolean | — | Enable text search filtering |
data-paginate | number | — | Enable pagination with N rows per page |
| Element | Required | Description |
|---|---|---|
<table> | yes | Standard HTML table — the component enhances it in place |
| Attribute | On | Values | Description |
|---|---|---|---|
data-sort | th | "string", "number", "date" | Enable sorting on this column with the specified comparator |
data-sort-value | td | string | Custom sort value overriding cell text |
data-filter-value | td | string | Custom filter value overriding cell text |
data-expandable | tr | boolean | Mark row as expandable (followed by a data-expand-content row) |
data-expand-content | tr | boolean | Hidden row revealed when its preceding expandable row is toggled |
data-selectable | tr | boolean | Mark row as selectable via checkbox |
These attributes are handled by CSS styling, not JavaScript logic.
| Attribute | On | Value | Description |
|---|---|---|---|
data-responsive | <table> | card | Transform to card layout on narrow screens. |
data-sticky | <table> | header | column | both | Make header and/or first column sticky. |
data-sticky-column | <table> | number | Number of columns to make sticky (default: 1). |
data-label | <td> | string | Label shown in responsive card mode. |
data-filter-value | <td> | string | Custom value for filtering instead of cell text. |
| Key | Action |
|---|---|
| Tab | Move between interactive elements (sort headers, checkboxes, buttons) |
| Enter / Space | Activate sort, toggle checkbox, or expand/collapse row |
| Arrow Up/Down | Navigate between rows when focused on a row |
| Home / End | Jump to first/last page in pagination |
aria-sort on sortable column headers indicates current sort directionaria-expanded on expand buttons indicates row expansion statearia-selected on selectable rows indicates selection statearia-live="polite" on filter results announces match countaria-label on pagination controls describes navigationWhen prefers-reduced-motion: reduce is set, all transitions and animations are disabled. Sort indicators, row expansion, and pagination all function without motion effects.