Skip to main content

Data Table

Stateless plain table with sorting, selection, nested rows, and TableVirtualized (mates-virtual) for large data sets.

Quick start
import { Table, TableVirtualized } from "mates-ui";
// TableVirtualized → mates-virtual virtualTable (peer dependency)

Basic table

import { html, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table } from 'mates-ui';

const USERS = [
{ id: "1", name: "Alice Chen", email: "alice@example.com", role: "Admin", status: "active", joined: "Jan 2024" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "Editor", status: "inactive", joined: "Feb 2024" },
{ id: "3", name: "Carol Jones", email: "carol@example.com", role: "Viewer", status: "pending", joined: "Mar 2024" },
{ id: "4", name: "David Brown", email: "david@example.com", role: "Admin", status: "active", joined: "Apr 2024" },
{ id: "5", name: "Eve Johnson", email: "eve@example.com", role: "Editor", status: "inactive", joined: "May 2024" },
];

const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };

const COLUMNS = [
{ id: "name", header: "Name", render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "email", header: "Email", render: (r) => html`<span style="color:var(--md-color-text-muted);">${r.email}</span>` },
{ id: "role", header: "Role", render: (r) => Chip(r.role, { variant: "assist" }) },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
{ id: "joined", header: "Joined", render: (r) => html`<span style="color:var(--md-color-text-muted);">${r.joined}</span>` },
];

const App = () => () => html`
${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id })}
`;

renderApp(App, document.getElementById('app'));

Variants — default / bordered / striped

import { html, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table, Text } from 'mates-ui';

const USERS = [
{ id: "1", name: "Alice Chen", email: "alice@example.com", role: "Admin", status: "active", joined: "Jan 2024" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "Editor", status: "inactive", joined: "Feb 2024" },
{ id: "3", name: "Carol Jones", email: "carol@example.com", role: "Viewer", status: "pending", joined: "Mar 2024" },
];
const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };
const COLUMNS = [
{ id: "name", header: "Name", render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "email", header: "Email", render: (r) => html`<span style="color:var(--md-color-text-muted);">${r.email}</span>` },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
];

const App = () => () => html`
<x-col gap="var(--md-space-5)">
${Text("default", { type: "label-sm", color: "muted" })}${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id })}
${Text("bordered", { type: "label-sm", color: "muted" })}${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, variant: "bordered" })}
${Text("striped", { type: "label-sm", color: "muted" })}${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, variant: "striped" })}
</x-col>
`;

renderApp(App, document.getElementById('app'));

Sortable columns

import { html, atom, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table } from 'mates-ui';

const USERS = [
{ id: "1", name: "Alice Chen", email: "alice@example.com", role: "Admin", status: "active", joined: "Jan 2024" },
{ id: "2", name: "Bob Smith", email: "bob@example.com", role: "Editor", status: "inactive", joined: "Feb 2024" },
{ id: "3", name: "Carol Jones", email: "carol@example.com", role: "Viewer", status: "pending", joined: "Mar 2024" },
{ id: "4", name: "David Brown", email: "david@example.com", role: "Admin", status: "active", joined: "Apr 2024" },
];
const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };
const COLUMNS = [
{ id: "name", header: "Name", sortable: true, render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "email", header: "Email", sortable: true, render: (r) => html`<span style="color:var(--md-color-text-muted);">${r.email}</span>` },
{ id: "role", header: "Role", sortable: true, render: (r) => Chip(r.role, { variant: "assist" }) },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
];

const App = () => {
const sortBy = atom("name");
const sortDir = atom("asc");
return () => html`
${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, sortBy: sortBy(), sortDir: sortDir(), onSort: (col, dir) => { sortBy.set(col); sortDir.set(dir); } })}
`;
};

renderApp(App, document.getElementById('app'));

Dense mode

import { html, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table } from 'mates-ui';

const USERS = [
{ id: "1", name: "Alice Chen", role: "Admin", status: "active" },
{ id: "2", name: "Bob Smith", role: "Editor", status: "inactive" },
{ id: "3", name: "Carol Jones", role: "Viewer", status: "pending" },
{ id: "4", name: "David Brown", role: "Admin", status: "active" },
];
const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };
const COLUMNS = [
{ id: "name", header: "Name", render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "role", header: "Role", render: (r) => Chip(r.role, { variant: "assist" }) },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
];

const App = () => () => html`
${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, dense: true })}
`;

renderApp(App, document.getElementById('app'));

Row selection

import { html, atom, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table, Text } from 'mates-ui';

const USERS = [
{ id: "1", name: "Alice Chen", role: "Admin", status: "active" },
{ id: "2", name: "Bob Smith", role: "Editor", status: "inactive" },
{ id: "3", name: "Carol Jones", role: "Viewer", status: "pending" },
{ id: "4", name: "David Brown", role: "Admin", status: "active" },
];
const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };
const COLUMNS = [
{ id: "name", header: "Name", render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "role", header: "Role", render: (r) => Chip(r.role, { variant: "assist" }) },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
];

const App = () => {
const sel = atom([]);
return () => html`
<x-col gap="var(--md-space-2)">
${sel().length > 0 ? Text(sel().length + " row(s) selected", { type: "body-sm", color: "muted" }) : Text("Click checkboxes to select rows.", { type: "body-sm", color: "muted" })}
${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, selectable: true, selected: sel(), onSelect: (keys) => sel.set(keys) })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Nested rows (tree expand)

import { html, atom, renderApp } from 'mates';
import { Avatar, Badge, Chip, Table, Text } from 'mates-ui';

const USERS = [
{ id: "eng", name: "Engineering", role: "Dept", status: "active", children: [
{ id: "1", name: "Alice Chen", role: "Admin", status: "active" },
{ id: "2", name: "Bob Smith", role: "Editor", status: "inactive" },
],
},
{ id: "des", name: "Design", role: "Dept", status: "active", children: [
{ id: "3", name: "Carol Jones", role: "Viewer", status: "pending" },
],
},
];
const STATUS_BADGE = { active: "success", inactive: "neutral", pending: "warning" };
const COLUMNS = [
{ id: "name", header: "Name", render: (r) => html`<div style="display:flex;align-items:center;gap:var(--md-space-2);">${Avatar({ name: r.name, size: "sm" })}<span>${r.name}</span></div>` },
{ id: "role", header: "Role", render: (r) => Chip(r.role, { variant: "assist" }) },
{ id: "status", header: "Status", render: (r) => Badge(r.status, { variant: STATUS_BADGE[r.status] }) },
];

const App = () => {
const expanded = atom([]);
const lastLabel = atom("");
return () => html`
<x-col gap="var(--md-space-2)">
${lastLabel() ? Text("Last expanded: " + lastLabel(), { type: "body-sm", color: "muted" }) : Text("Expand a department to see members.", { type: "body-sm", color: "muted" })}
${Table({ columns: COLUMNS, rows: USERS, rowKey: (r) => r.id, variant: "striped", nestedRows: true, expandedKeys: expanded(), onExpandedChange: (keys) => expanded.set(keys), onRowExpand: (r) => lastLabel.set(r.name) })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Virtualized body

import { html, atom, renderApp } from 'mates';
import { TableVirtualized, Text } from 'mates-ui';

const ROWS = Array.from({ length: 500 }, (_, i) => ({
id: String(i + 1),
name: "User " + (i + 1),
email: "user" + (i + 1) + "@example.com",
role: (["Admin", "Editor", "Viewer"])[i % 3],
status: (["active", "inactive", "pending"])[i % 3],
}));

const COLUMNS = [
{ id: "name", header: "Name", width: 192, minWidth: 120, accessor: (r) => r.name, sortable: true },
{ id: "email", header: "Email", width: 256, minWidth: 160, accessor: (r) => r.email, sortable: true },
{ id: "role", header: "Role", width: 128, minWidth: 80, accessor: (r) => r.role, sortable: true },
{ id: "status", header: "Status", width: 112, minWidth: 80, accessor: (r) => r.status, sortable: true },
];

const App = () => {
const sortBy = atom("name");
const sortDir = atom("asc");
return () => html`
<x-col gap="var(--md-space-2)" style="width:100%;">
${Text("500 rows — only visible rows are in the DOM.", { type: "body-sm", color: "muted" })}
${TableVirtualized({ columns: COLUMNS, rows: ROWS, rowKey: (r) => r.id, bodyHeight: "22.5rem", variant: "striped", stickyHeader: true, sortBy: sortBy(), sortDir: sortDir(), onSort: (col, dir) => { sortBy.set(col); sortDir.set(dir); } })}
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Table / TableVirtualized

import { Table, TableVirtualized } from "mates-ui";
Prop Type Default Description
attr AttrMap Extra HTML attributes on the root element.
bodyHeight string CSS height of the scrollable body (TableVirtualized).
classes string | string[] Extra root classes — joined and appended after built-in classes.
columns* TableColumn<T>[] Column definitions.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
dense boolean false Reduces cell padding.
emptyText string "No data" Message shown when rows is empty.
expandedKeys string[] Controlled set of expanded row keys (nestedRows).
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
lazyLoadRootMargin string IntersectionObserver margin for the bottom sentinel.
loading boolean false Shows skeleton rows instead of data.
loadingRows number 5 Number of skeleton rows shown while loading.
nestedRows boolean false Enables tree-style child row expansion.
onExpandedChange (keys: string[]) => void Called when expanded keys change.
onRowClick (row: T) => void Called when a row is clicked.
onRowExpand (row: T) => void Called when a row is expanded.
onScrolledIntoView () => void Called when the bottom sentinel enters the viewport.
onSelect (keys: string[]) => void Called when the selection changes.
onSort (col: string, dir: SortDir) => void Called when a sortable header is clicked.
rowKey* (row: T) => string Unique key extractor per row.
rows* T[] Data rows to render.
selectable boolean false Enables row checkboxes and select-all.
selected string[] Controlled set of selected row keys.
sortBy string Currently sorted column id.
sortDir "asc" | "desc" Current sort direction.
stickyHeader boolean false Fixes the header row while the body scrolls (TableVirtualized).
variant "default" | "bordered" | "striped" "default" Visual style of the table.

TableColumn<T>

Prop Type Default Description
align "left" | "center" | "right" "left" Horizontal cell alignment.
header* string Column header label.
id* string Unique column identifier; matched against sortBy.
render* (row: T) => TemplateResult Cell render function.
sortable boolean When true, clicking the header emits onSort.
width string CSS grid track sizing hint (e.g. minmax(8rem, 1fr)).

CSS tokens

Token Role
--md-color-border Cell and header border colour.
--md-color-primary Sort arrow and checkbox accent.
--md-color-surface Table background.
--md-color-surface-raised Striped alternate row background.
--md-color-text-muted Secondary cell text colour.
--md-radius-md Outer table border radius.

Caveats

  • Table and TableVirtualized are stateless — always pass sortBy, sortDir, selected, and expandedKeys as controlled props.
  • TableVirtualized requires bodyHeight; use column width hints so the header grid template matches the row grid template.
  • TableVirtualized uses mates-virtual virtualTable under the hood — install mates-virtual alongside mates-ui.
  • nestedRows uses a children field on the row type; rows without children are leaves.