Table
A data table with client sorting, row selection, a sticky header, built-in empty and loading states, and an opt-in stacked mode for narrow widths. Real <table> semantics throughout.
Import
import { Table } from "@hyzer-labs/ui"Demo
Large datasets
<table> semantics give you native screen-reader table navigation for
free, so reach for this component up to some thousands of rows. Past the point where rendering
every <tr> becomes the bottleneck, see the Virtualized table pattern, which windows an ARIA
table with Virtualizer instead.A framed table with rules between the rows. Inside a card or panel that already draws
its own edges, bordered=false drops the frame and those rules. The divider
under the header stays either way.
| Voyager | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar | Putter | 2 | 9.99 |
| Roc | Midrange | 5 | 14.99 |
| Wraith | Distance | 11 | 21.99 |
| Buzzz | Midrange | 5 | 15.99 |
| Judge | Putter | 2 | 11.99 |
| Firebird | Distance | 9 | 18.99 |
| Zone | Midrange | 4 | 13.99 |
interface Disc {
id: string;
name: string;
type: string;
speed: number;
price: number;
}
const items: Disc[] = [
{ id: 'd1', name: 'Voyager', type: 'Distance', speed: 9, price: 19.99 },
{ id: 'd2', name: 'Aviar', type: 'Putter', speed: 2, price: 9.99 },
// …
];
// Each column's `key` names a Disc property: Table renders `row[key]`
// by default (String(row.name), String(row.price), …).
const columns: TableColumn<Disc>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'type', header: 'Type', sortable: true },
{ key: 'speed', header: 'Speed', sortable: true, align: 'end' },
{ key: 'price', header: 'Price', sortable: true, align: 'end', width: '7rem' }
];
<Table {items} {columns} caption="Discs" />clientSort (default) reorders items itself.
| Voyager | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar | Putter | 2 | 9.99 |
| Roc | Midrange | 5 | 14.99 |
| Wraith | Distance | 11 | 21.99 |
| Buzzz | Midrange | 5 | 15.99 |
| Judge | Putter | 2 | 11.99 |
| Firebird | Distance | 9 | 18.99 |
| Zone | Midrange | 4 | 13.99 |
<!-- clientSort defaults to true: Table reorders `items` itself. -->
<Table {items} {columns} caption="Discs sorted client-side" bind:sort />With clientSort={false}, Table only reports and marks the sort
state. This demo reorders its own local array in response.
| Voyager | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar | Putter | 2 | 9.99 |
| Roc | Midrange | 5 | 14.99 |
| Wraith | Distance | 11 | 21.99 |
| Buzzz | Midrange | 5 | 15.99 |
| Judge | Putter | 2 | 11.99 |
| Firebird | Distance | 9 | 18.99 |
| Zone | Midrange | 4 | 13.99 |
let items = $state(discs);
let sort = $state(null);
// clientSort={false}: Table leaves `items` alone, YOU reorder it.
$effect(() => {
if (sort) items = reorder(discs, sort);
});
<Table {items} {columns} clientSort={false} bind:sort caption="Discs, externally ordered" />Columns are sortable only when their own sortable flag says so. The
plain columns config below (like the docs' own PropsTable) omits
it, so no column renders a sort button and none carries aria-sort.
| Name | Type | Price |
|---|---|---|
| Voyager | Distance | 19.99 |
| Aviar | Putter | 9.99 |
| Roc | Midrange | 14.99 |
| Wraith | Distance | 21.99 |
| Buzzz | Midrange | 15.99 |
| Judge | Putter | 11.99 |
| Firebird | Distance | 18.99 |
| Zone | Midrange | 13.99 |
const columns: TableColumn<Disc>[] = [
{ key: 'name', header: 'Name' },
{ key: 'type', header: 'Type' },
{ key: 'price', header: 'Price', align: 'end' }
];
<Table {items} {columns} caption="Discs" />Selected: none
| Voyager | Distance | 9 | 19.99 | |
|---|---|---|---|---|
| Aviar | Putter | 2 | 9.99 | |
| Roc | Midrange | 5 | 14.99 | |
| Wraith | Distance | 11 | 21.99 | |
| Buzzz | Midrange | 5 | 15.99 | |
| Judge | Putter | 2 | 11.99 | |
| Firebird | Distance | 9 | 18.99 | |
| Zone | Midrange | 4 | 13.99 |
const selected = new SvelteSet<string>();
<Table
{items}
{columns}
caption="Select discs"
selectable
bind:selected
getRowId={(row) => row.id}
/>stickyHeader pins thead against the wrap's own scroll. Cap .hz-table-wrap's height (here, from a wrapping div) to see it
scroll.
| Voyager #1 | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar #2 | Putter | 2 | 9.99 |
| Roc #3 | Midrange | 5 | 14.99 |
| Wraith #4 | Distance | 11 | 21.99 |
| Buzzz #5 | Midrange | 5 | 15.99 |
| Judge #6 | Putter | 2 | 11.99 |
| Firebird #7 | Distance | 9 | 18.99 |
| Zone #8 | Midrange | 4 | 13.99 |
| Voyager #9 | Distance | 9 | 19.99 |
| Aviar #10 | Putter | 2 | 9.99 |
| Roc #11 | Midrange | 5 | 14.99 |
| Wraith #12 | Distance | 11 | 21.99 |
| Buzzz #13 | Midrange | 5 | 15.99 |
| Judge #14 | Putter | 2 | 11.99 |
| Firebird #15 | Distance | 9 | 18.99 |
| Zone #16 | Midrange | 4 | 13.99 |
<div class="scroll-wrap">
<Table {items} {columns} caption="Discs (scroll for more)" stickyHeader />
</div>
/* your CSS */
.scroll-wrap .hz-table-wrap { max-height: 16rem; overflow-y: auto; }Stacking is off until you set stack. With stack="sm", each row
becomes a label/value block below --hz-width-sm (640px) instead of
scrolling horizontally. sm suits most tables, since they then stack only on
genuinely narrow viewports; md and lg are there for tables with
many or wide columns. Drag the slider across the threshold to see it engage.
table (640px+)
| Voyager | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar | Putter | 2 | 9.99 |
| Roc | Midrange | 5 | 14.99 |
| Wraith | Distance | 11 | 21.99 |
| Buzzz | Midrange | 5 | 15.99 |
| Judge | Putter | 2 | 11.99 |
| Firebird | Distance | 9 | 18.99 |
| Zone | Midrange | 4 | 13.99 |
<Table {items} {columns} caption="Discs" stack="sm" />
/* stacks below --hz-width-sm (640px); a real table at/above it */| Voyager | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar | Putter | 2 | 9.99 |
| Roc | Midrange | 5 | 14.99 |
| Wraith | Distance | 11 | 21.99 |
| Buzzz | Midrange | 5 | 15.99 |
| Judge | Putter | 2 | 11.99 |
| Firebird | Distance | 9 | 18.99 |
| Zone | Midrange | 4 | 13.99 |
<Table
items={discs}
{columns}
caption="Discs"
/>Table has no built-in pagination. Compose Pagination alongside it instead, as this demo does over a 22-item catalog.
| Voyager #1 | Distance | 9 | 19.99 |
|---|---|---|---|
| Aviar #2 | Putter | 2 | 9.99 |
| Roc #3 | Midrange | 5 | 14.99 |
| Wraith #4 | Distance | 11 | 21.99 |
| Buzzz #5 | Midrange | 5 | 15.99 |
| Judge #6 | Putter | 2 | 11.99 |
const PER_PAGE = 6;
let page = $state(1);
const visible = $derived(catalog.slice((page - 1) * PER_PAGE, page * PER_PAGE));
<Table items={visible} {columns} caption="Discs" />
<Pagination count={pageCount} bind:page ariaLabel="Discs pages" />Props
| Name | Type | Default | Note |
|---|---|---|---|
items | T[] | — | Required. |
columns | TableColumn<T>[] | — | Required. Each column's `key` is a property name on T — the default cell reads `row[key]`. See TableColumn below. |
caption | string | Snippet | — | Visible caption. `caption` or `ariaLabel` is required — logs a warning in development if neither is set. Caption wins if both are given (no aria-label). |
ariaLabel | string | — | See `caption`. |
sort | TableSort | null | null | Bindable. See TableSort below. |
clientSort | boolean | true | false: Table renders `items` as given and only reports/marks sort state — you reorder `items` yourself. |
selectable | boolean | false | Prepends a checkbox column. |
selected | SvelteSet<string> | new SvelteSet() | Bindable — row ids (via getRowId) currently selected. |
getRowId | (row: T, index: number) => string | stringified index | Row identity for selection. Supply a real id so selection survives re-sorting. |
rowLabel | (row: T) => string | — | Accessible name for a row's checkbox; falls back to the first column's cell text. |
stickyHeader | boolean | false | |
bordered | boolean | true | Frame and row rules. Set false inside a card or panel that already draws the edges; the divider under the header stays either way. |
loading | boolean | false | |
loadingRows | number | 3 | |
stack | 'sm' | 'md' | 'lg' | — | Stacks below the named width. Off by default (scroll wrap only); 'sm' (640px) suits most tables — reserve md/lg for wide or many-column tables that need to shed the table layout earlier. |
cell | Snippet<[T, TableColumn<T>]> | — | Default is `String(row[column.key])`. |
empty | Snippet | — | Default is "No rows". |
class | string | — | Merged after hz-table-wrap. |
TableColumn<T>
| Name | Type | Default | Note |
|---|---|---|---|
key | string | — | Required. A property name on a row of T (the default cell renders `row[key]`) and the column id (sort target, stacked-mode data-label). |
header | string | — | Required. Header cell text (also the stacked-mode label). |
sortable | boolean | false | |
sortBy | (row: T) => string | number | row[key] | The escape hatch for mixed-type or computed sort values. |
align | 'start' | 'center' | 'end' | 'start' | Logical — flips under RTL. |
width | string | — | CSS width for the column's <col>. |
TableSort
| Name | Type | Default |
|---|---|---|
key | string | — |
direction | 'asc' | 'desc' | — |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-table hz-table-wrap
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-bordered | present unless bordered={false} | On .hz-table-wrap. The reference theme keys the frame and the row rules off it, so `bordered={false}` leaves an edgeless table for a card or panel that draws its own edges. The heavier divider under the header stays either way, since a borderless table still needs its header separated from its body. |
data-sticky | present when stickyHeader | On .hz-table-wrap. Pins thead against the wrap’s own scroll — cap the wrap’s max-height (e.g. via your own class through Table’s `class` prop, which lands on the wrap) to see it scroll. |
data-stack | 'sm' | 'md' | 'lg' | On .hz-table-wrap. Mirrors the stack prop — stacked below the named --hz-width-* threshold, a real table at/above it. Absent (default): never stacks. 'sm' (640px) is the recommended default — genuinely narrow viewports only. |
data-selected | present on a selected row | On tbody tr. Pairs with aria-selected="true" — both present only when selected. |
data-align | 'start' | 'center' | 'end' | On th/td, mirroring a column’s align. Logical values, so start/end flip under RTL with no extra rule. |
aria-sort | 'ascending' | 'descending' | Not a data-* hook, but load-bearing for styling the active-column indicator: present only on the sorted column’s th, never on the others. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-table-sort | child element | The sort trigger — a real button wrapping a sortable column’s header text, with the active-column chevron alongside it. |
.hz-table-empty | child element | The full-width cell rendered when items is empty and not loading. |
.hz-table-skeleton | child element | A loading placeholder row — on tbody tr; aria-hidden, so it never reaches assistive tech. |
.hz-table--striped | opt-in class | Zebra striping. Pass it via class — like Card’s treatment classes, this is a theme look, not a prop. |
Accessibility
This is a static data table, not an APG grid pattern. Sort buttons and checkboxes are ordinary native controls in the natural tab order, so there is no roving grid navigation.
aria-sort (ascending/descending) sits only on the sorted column's th, never on the others. Select-all announces the partial state through the native indeterminate property, not an ARIA attribute.
Loading skeleton rows carry aria-hidden, so they never reach assistive tech.
Real role/scope semantics are stamped explicitly (table/row/columnheader/cell, plus scope), so they survive the stacked mode's CSS display overrides.
References: APG sortable table example · MDN — accessible data tables