Data Table

Free

Sortable, paginated table with row selection, badge cells and a loading skeleton — plus premium toolbar, column filters, inline editing, expandable rows and bulk actions.

</>sv-data-table tablegriddata

Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.

import { SvDataTableComponent } from '@styloviz/data-table';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Searchable toolbar + bulk actions

example.htmlAngular
<sv-data-table
  [rows]="rows"
  rowKey="id"
  [searchable]="true"
  [showToolbar]="true"
  [selectable]="true"
  [bulkActions]="bulkActions"
  [pageSize]="20"
  (rowClick)="open($event)" />

Server-side paging

example.htmlAngular
<sv-data-table
  [rows]="pageRows"
  [serverSide]="true"
  [totalCount]="total"
  (serverPageChange)="load($event)" />

Transparent — inherit the container background (Pro)

example.htmlAngular
<!-- Drops the table's own white fill so it blends into a glass card
     or coloured panel; keeps hover/selection tints. Pair with
     variant="borderless" so only the parent card's border shows. -->
<div class="rounded-2xl border bg-white/70 backdrop-blur-xl p-1">
  <sv-data-table-pro [columns]="columns" [rows]="rows" rowKey="id"
    [transparent]="true" variant="borderless" [paginated]="false" />
</div>

Custom cell templates (svDtCell)

example.htmlAngular
<sv-data-table [columns]="columns" [rows]="rows" rowKey="id">
  <!-- Render any rich content per column, keyed by the column's `key`. -->
  <ng-template svDtCell="name" let-row let-value="value">
    <sv-avatar [name]="row.name" size="xs" /> {{ value }}
  </ng-template>
  <ng-template svDtCell="status" let-row>
    <sv-badge [label]="row.status" [variant]="statusVariant(row.status)" />
  </ng-template>
</sv-data-table>

Compare products, transposed (Pro)

example.htmlAngular
<!-- Transposes rows→columns: first column = product name, the rest
     become feature rows; boolean values render as check / cross. -->
<sv-data-table-pro [columns]="columns" [rows]="products" rowKey="id"
  layout="comparison" highlightRowId="pro" />

When to use

  • Displaying structured rows that users sort, page, search, or select.
  • You need selection, bulk actions, density toggles or column visibility.
  • For unstructured/feed-style content, use List View instead.
  • Up to ~200 rows: render them unpaged. Every row is in the DOM, so nothing needs configuring.
  • 200 rows to ~10k, held in the browser: `paginated` (or `virtualized` for one continuous scroll). The client pipeline — search, filter, sort and windowing over the whole set — measures 0.5ms at 1k rows and 4.4ms at 10k, so a keystroke still lands inside one frame.
  • 10k to ~25k: `virtualized` keeps rendering flat (the DOM holds `viewportHeight / virtualRowHeight` rows plus a small overscan no matter how large the set is — a 240-row table renders 13 `tr`s), but the pipeline is linear in row count and search is what runs it. Turn `searchable` off, or move to `serverSide`.
  • Beyond ~25k rows: `serverSide`. The pipeline costs 21ms at 50k rows, so each keystroke drops a frame — and that is before the memory of holding the set client-side.

Async states

  • loading
  • empty
  • error
  • retry
  • partial

Item identity & owned state

Item key
rowKey — set this when your rows have no id, so selection survives a reorder.
Restorable state
  • expandedRowIds
  • hiddenColumnKeys
  • revealedColumnKeys
  • selectedIds
  • selectionChange

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Inputs

NameTypeDefaultDescription
columnsTableColumn<T>[][]Column definitions (key, header, alignment, optional cell template).
rowsreadonly T[][]The rows to render. `readonly` on purpose: the table never mutates this array, and anything that hands it one — a signal, a `computed`, `svDataController.items` — is handing over a value it still owns. A mutable `T[]` here rejected every one of those at the template boundary while promising a write the table does not perform.
rowKeySvItemKey<T>'id'What identifies a row: a property name, or a function for composite keys. Widened from `string` because a row identified by two fields — an order line keyed on `orderId` + `lineNo`, a matrix cell on `row` + `col` — had no way to say so, and the fallback silently keyed it on the whole row's JSON. See the identity contract on `resolveItemKey` in `@styloviz/core`.
selectablebooleanfalseEnable row selection.
selectModeSelectMode'multi''multi' = checkboxes, 'single' = radio buttons.
selectedIdsSet<string | number> (two-way)new Set()Selected row ids (two-way).
expandedRowIdsSet<string | number> (two-way)new Set()Which rows have their detail panel open (two-way). A `model` rather than internal state because it is the same kind of fact as `selectedIds`: a host restoring a table after a route change, or opening the row a deep link names, has to be able to write it — and one that persists layout has to be able to see it change. Held in a plain signal it was neither settable nor observable from outside.
actionsTableRowAction<T>[][]Per-row action buttons.
bulkActionsBulkAction[][]Bulk actions applied to selected rows.
loadingbooleanfalseShow a loading state.
loadingStyleLoadingStyle'skeleton''skeleton' rows or full-table 'overlay' spinner.
skeletonRowsnumber5Number of skeleton rows while loading.
paginatedbooleantrueEnable client-side pagination.
pageSizenumber (two-way)10Rows per page (two-way).
pageSizeOptionsnumber[][10, 25, 50]Page-size options offered in the toolbar.
serverSidebooleanfalseServer-side mode: disables client filtering/sorting/paging.
totalCountnumber | nullnullTotal row count when in server-side mode.
variantTableVariant'default'Table style variant.
densityTableDensity'comfortable'Row density: comfortable, compact, etc.
compactbooleanfalseDeprecated shorthand for compact density.
maxHeightstring | nullnullMax body height (CSS length) before the table scrolls.
showScrollHintbooleantrueShow a hint when the table is horizontally scrollable.
rowClassFn((row: T) => string) | nullnullCallback returning extra Tailwind classes for a given row.
showToolbarboolean | undefinedundefinedShow the toolbar strip. Left unset it DERIVES from whether the toolbar has anything to put in it — binding `tableTitle` or `[searchable]` used to render nothing at all unless you also remembered this flag, which is a silent no-op: the API accepted your input and ignored it. Set it explicitly to force the strip on (an empty bar as a spacer) or off (a title used only as the accessible name). Every existing call site passes it, so nothing changes for them.
tableTitlestring''Title shown in the toolbar.
searchablebooleanfalseShow a search box in the toolbar.
showColumnTogglebooleanfalseShow the column visibility toggle.
exportablebooleanfalseShow an export (CSV) control.
showDensityTogglebooleanfalseShow the density toggle.
columnFiltersbooleanfalseEnable per-column filters.
multiSortbooleanfalseAllow sorting by multiple columns.
expandablebooleanfalseEnable expandable rows.
expandRender((row: T) => string) | nullnullRenderer for the expanded row detail.
showFooterbooleanfalseShow a table footer row.
cardOnMobilebooleanfalseRender rows as cards on small screens.
emptyMessagestring'No data to display.'Message shown when there are no rows.
customClassstring''Extra CSS classes for the root element.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. Rendered in place of the rows, above the toolbar so the user can still change the filter that caused it.
hiddenColumnKeysSet<string> (two-way)new Set()Which columns the user has hidden (two-way). A `model` rather than internal state so a saved table layout can be restored by binding it — the free tier had no other way in at all, and the Pro tier could only reach it through a whole-state snapshot. Declared here with the rest of the internal view state because that is where the column toggle lives; it is nonetheless part of the public API.
revealedColumnKeysReadonlySet<string> (two-way)new Set()Columns declared `hidden: true` that the user has since shown from the Columns menu. `hidden` is documented as "initially hidden — the user can show it from the column-toggle panel", but the table used to filter `!col.hidden` unconditionally: the column could never come back, and its checkbox (bound to `hiddenColumnKeys` alone) sat TICKED for a column that was not on screen. Two-way, like `hiddenColumnKeys`: together the two ARE the column-visibility view, and a host that persists one without the other restores a table whose revealed columns have vanished again (`check:collection-identity`).

Outputs

EventPayload typeDescription
sortChangeSortState[]Emitted with the new sort state when sorting changes.
selectionChangeSet<string | number>Emitted with the set of selected row ids.
rowClickTEmitted with the clicked row.
cellClickCellClickEvent<T>Emitted with the clicked cell context.
editCompleteEditCompleteEvent<T>Emitted when an inline cell edit completes (Pro).
retryvoidThe host should re-run the failed load.
queryChangeSvDataQueryEverything the user is asking to see, in the kit's shared shape. This is the output to wire in `serverSide` mode: one handler regardless of whether they paged, sorted, filtered or searched, and the same `SvDataQuery` every other component in the kit emits — so `@styloviz/data` and a hand-rolled fetch both take it without a translation layer.
serverPageChangeServerPageEventEmitted in `serverSide` mode with the page, page size, sorts, column filters, search text and request id whenever the user changes any of them.
rowExpandTEmitted with the row when the user expands or collapses its detail panel.
bulkActionClick{ action: BulkAction; selectedIds: Set<string | number> }Emitted with the bulk action and the selected row ids.
selectedIdsChangeSet<string | number>Emits the new `selectedIds` whenever it changes — the output half of `[(selectedIds)]`.
expandedRowIdsChangeSet<string | number>Emits the new `expandedRowIds` whenever it changes — the output half of `[(expandedRowIds)]`.
pageSizeChangenumberEmits the new `pageSize` whenever it changes — the output half of `[(pageSize)]`.
hiddenColumnKeysChangeSet<string>Emits the new `hiddenColumnKeys` whenever it changes — the output half of `[(hiddenColumnKeys)]`.
revealedColumnKeysChangeReadonlySet<string>Emits the new `revealedColumnKeys` whenever it changes — the output half of `[(revealedColumnKeys)]`.

<sv-data-table-pro>

Also in this family — SvDataTableProComponent from @styloviz-pro/data-table.

Async states

  • loading
  • empty
  • error
  • retry
  • partial

Item identity & owned state

Item key
rowKey — set this when your rows have no id, so selection survives a reorder.
Restorable state
  • expandedRowIds
  • hiddenColumnKeys
  • revealedColumnKeys
  • selectedIds
  • selectionChange

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Saved state

Stored kinds
  • sv-data-table/layout

What you will find under your own storage key. You choose the key; the blob names its own kind and schema version, so a component never reads state another one wrote.

Reading it back
Restore through the component's own restore… helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.

Inputs

NameTypeDefaultDescription
columnsTableColumn<T>[][]Column definitions: key, label, alignment, and each column's sorting, filtering, rendering and editing options.
rowsreadonly T[][]The rows to render. `readonly` on purpose: the table never mutates this array, and anything that hands it one — a signal, a `computed`, `svDataController.items` — is handing over a value it still owns. A mutable `T[]` here rejected every one of those at the template boundary while promising a write the table does not perform.
rowKeySvItemKey<T>'id'What identifies a row: a property name, or a function for composite keys. Widened from `string` because a row identified by two fields — an order line keyed on `orderId` + `lineNo`, a matrix cell on `row` + `col` — had no way to say so, and the fallback silently keyed it on the whole row's JSON. See the identity contract on `resolveItemKey` in `@styloviz/core`.
selectablebooleanfalseAdd a selection column of checkboxes (or radio buttons, per `selectMode`).
selectModeSelectMode'multi''multi' = checkboxes, 'single' = radio buttons.
selectedIdsSet<string | number> (two-way)new Set()Ids of the selected rows, as resolved by `rowKey` (two-way).
expandedRowIdsSet<string | number> (two-way)new Set()Which rows have their detail panel open (two-way). A `model` rather than internal state because it is the same kind of fact as `selectedIds`: a host restoring a table after a route change, or opening the row a deep link names, has to be able to write it — and one that persists layout has to be able to see it change. Held in a plain signal it was neither settable nor observable from outside.
actionsTableRowAction<T>[][]Per-row actions, offered in a menu at the end of each row; an action's `hidden` callback can drop it for a given row.
bulkActionsBulkAction[][]Actions shown in a bar above the table while any row is selected; each receives the selected ids and also emits `bulkActionClick`.
loadingbooleanfalseShow the loading state (skeleton rows or an overlay spinner, per `loadingStyle`) and mark the table `aria-busy`.
loadingStyleLoadingStyle'skeleton''skeleton' rows or full-table 'overlay' spinner.
skeletonRowsnumber5Number of placeholder rows (and mobile cards) rendered while `loading` with the skeleton style.
paginatedbooleantrueSplit rows into pages with a pagination bar. Has no effect when `virtualized` or `groupBy` is set.
pageSizenumber (two-way)10Rows per page (two-way); also updated when the user picks a size in the pagination bar.
pageSizeOptionsnumber[][10, 25, 50]Choices in the pagination bar's rows-per-page select; an empty array hides the select.
serverSidebooleanfalseServer-side mode: disables client filtering/sorting/paging.
totalCountnumber | nullnullTotal row count across all pages in `serverSide` mode, used for the range label and page count; falls back to `rows.length` when null.
virtualizedbooleanfalseWindows row rendering to what's scrolled into view, using the same variable-height primitive (`createVariableVirtualizer`, `@styloviz/core`) `list-view` uses — a group header and a data row are different heights, so this table always needs the variable form, never the fixed one. Ignores `paginated`: virtualization and client pagination solve the same problem (a huge row set costing too much DOM), so turning this on reads as opting OUT of pagination rather than stacking both — the same relation `groupBy` has to pagination, and for the same reason.
virtualRowHeightnumber44Approximate pixel height of one data row, used to compute the virtualized window. Deliberately approximate — same honesty as `list-view`'s `itemHeight`: measure your own rendered row height for pixel accuracy. A wrong guess still renders every row correctly, just with a slightly-off scrollbar length until the next scroll event corrects it.
virtualGroupRowHeightnumber40Pixel height of a collapsible group header row, when `groupBy` is also set.
cardRowHeightnumber96Pixel height of one `cardOnMobile` card, used to window the card list on the same `virtualized` switch as the table. A card is taller than a row and its height depends on how many columns are visible — a two-column grid of label/value pairs, so roughly `ceil(columns / 2) * 46 + 32`. Approximate for the same reason `virtualRowHeight` is: a wrong guess costs scrollbar accuracy, never correctness.
viewportHeightnumber400Pixel height of the scrolling viewport when `virtualized` is on. A dedicated numeric input rather than reusing `maxHeight` — `maxHeight` accepts any CSS unit (`'50vh'`, `'none'`) that cannot be safely coerced to a pixel count, and the windowing math needs a real number to divide by.
groupBystring | nullnullColumn key to section rows by, with a collapsible header per distinct value. Single-level only — a deliberate scope line: nested/multi-level grouping (which parent collapses which children, aggregate-of-aggregates) is real complexity no showcase use case has asked for, and this shape is additive if a future version needs it.
defaultGroupsExpandedbooleantrueWhether a group starts expanded or collapsed the first time it's seen.
variantTableVariant'default'Visual style: 'default', 'striped' (tinted alternate rows), 'bordered' (cell borders) or 'borderless' (no outer border or row dividers).
layoutTableLayout'default'Layout mode. 'comparison' transposes rows into columns for a product-comparison grid.
highlightRowIdstring | number | nullnullrowKey value of the product column to highlight as "recommended" in comparison layout.
transparentbooleanfalseRemoves the table's own surface fill (root, toolbar, body and rows) so it inherits the background of its container — e.g. when nested inside a glass card or a coloured panel. Row hover/selection tints and the `striped` variant's odd-row tint are preserved. Also drops the card shadow.
densityTableDensity'comfortable'Row padding and text size: 'compact', 'comfortable' or 'spacious'. A choice made in the density toggle overrides it.
compactbooleanfalseDeprecated shorthand for compact density.
maxHeightstring | nullnullMaximum height (any CSS length) of the table's scroll area before it scrolls vertically; ignored when `virtualized`, which uses `viewportHeight`.
showScrollHintbooleantrueShow a fade gradient along the right edge hinting that the table scrolls horizontally.
rowClassFn((row: T) => string) | nullnullCallback returning extra Tailwind classes for a given row.
showToolbarboolean | undefinedundefinedShow the toolbar strip. Left unset it DERIVES from whether the toolbar has anything to put in it — binding `tableTitle` or `[searchable]` used to render nothing at all unless you also remembered this flag, which is a silent no-op: the API accepted your input and ignored it. Set it explicitly to force the strip on (an empty bar as a spacer) or off (a title used only as the accessible name). Every existing call site passes it, so nothing changes for them.
tableTitlestring''Heading shown in the toolbar; also the table's accessible name and the title of Excel and PDF exports.
searchablebooleanfalseShow a toolbar search box that filters rows by text in any column (sent as `search` in `serverSide` mode).
showColumnTogglebooleanfalseShow a toolbar menu for hiding and showing columns.
exportablebooleanfalseShow a toolbar export control offering the formats in `exportFormats`.
exportFormatsreadonly TableExportFormat[]['csv']Which formats the export control offers, in menu order. One format renders a plain button; more than one renders a menu. Defaults to CSV alone so an existing `[exportable]="true"` keeps behaving exactly as before — Excel and PDF are opt-in, so a table only offers them where the host asks (PDF also fetches the `jspdf` / `html2canvas` engine as a lazy chunk on first use).
exportFileNamestring''Base filename for exports, without extension.
showDensityTogglebooleanfalseShow a toolbar control that lets the user switch row density.
columnFiltersbooleanfalseAdd a filter row beneath the header with a text or select filter per column (per each column's `filterType`).
multiSortbooleanfalseLet Shift+click on a header add a secondary sort instead of replacing the current one.
resizableColumnsbooleanfalseAllow users to drag column edges to resize.
reorderableColumnsbooleanfalseAllow users to drag headers (or Ctrl/Cmd+←/→) to reorder columns.
expandablebooleanfalseAdd a toggle to each row that opens a detail panel beneath it.
expandRender((row: T) => string) | nullnullReturns the plain text shown in an expanded row's detail panel; when null, the panel lists every column's label and value.
showFooterbooleanfalseShow the summary footer row built from each column's `footerFn`; nothing renders unless at least one column defines one.
cardOnMobilebooleanfalseBelow the `sm` breakpoint, render rows as stacked cards instead of the table.
emptyMessagestring'No data to display.'Text shown when there are no rows to display.
customClassstring''Extra CSS classes appended to the root element.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. Rendered in place of the rows, above the toolbar so the user can still change the filter that caused it.
hiddenColumnKeysSet<string> (two-way)new Set()Which columns the user has hidden (two-way). A `model` rather than internal state so a saved table layout can be restored by binding it — the free tier had no other way in at all, and the Pro tier could only reach it through a whole-state snapshot. Declared here with the rest of the internal view state because that is where the column toggle lives; it is nonetheless part of the public API.
revealedColumnKeysReadonlySet<string> (two-way)new Set()Columns declared `hidden: true` that the user has since shown from the Columns menu. `hidden` is documented as "initially hidden — the user can show it from the column-toggle panel", but the table used to filter `!col.hidden` unconditionally: the column could never come back, and its checkbox (bound to `hiddenColumnKeys` alone) sat TICKED for a column that was not on screen. Two-way, like `hiddenColumnKeys`: together the two ARE the column-visibility view, and a host that persists one without the other restores a table whose revealed columns have vanished again (`check:collection-identity`).

Outputs

EventPayload typeDescription
sortChangeSortState[]Emitted with the full list of active sorts (empty when cleared) whenever the user changes the sort.
selectionChangeSet<string | number>Emitted with the new set of selected row ids whenever the user changes the selection.
rowClickTEmitted with the row when the user clicks a row or one of its cells, or activates it with Enter.
cellClickCellClickEvent<T>Emitted with the row, column and displayed value when the user clicks a cell; `rowClick` fires as well.
editCompleteEditCompleteEvent<T>Emitted with the row, column, old and new value when an inline cell edit is committed with a valid, changed value, so the host can persist it.
retryvoidThe host should re-run the failed load.
queryChangeSvDataQueryEverything the user is asking to see, in the kit's shared shape. This is the output to wire in `serverSide` mode: one handler regardless of whether they paged, sorted, filtered or searched, and the same `SvDataQuery` every other component in the kit emits — so `@styloviz/data` and a hand-rolled fetch both take it without a translation layer.
serverPageChangeServerPageEventEmitted in `serverSide` mode with the page, page size, sorts, column filters, search text and request id whenever the user changes any of them.
rowExpandTEmitted with the row when the user expands or collapses its detail panel.
bulkActionClick{ action: BulkAction; selectedIds: Set<string | number> }Emitted with the bulk action and a snapshot of the selected row ids when the user runs a bulk action.
columnResize{ key: string; width: number }A column finished resizing (pointer drag or ←/→ on a focused handle) — emits the column key and its new pixel width, so a host can persist the layout.
columnOrderChangestring[]Columns were reordered by drag — emits the full column-key order, left to right.
exportFailed{ format: TableExportFormat; error: Error }An export failed. PDF loads `jspdf` / `html2canvas` at click time, so a host that has not installed them learns about it here rather than through an unhandled rejection and a button that appears to do nothing.
groupToggle{ key: string; collapsed: boolean }A group was expanded or collapsed by the user, with its new state.
layoutStateChangeTableLayoutStateFires whenever anything `TableLayoutState` describes changes — column order/width/visibility, sort, column filters, page size, or which groups are collapsed — so a host knows there is something new to save. Store `serializeLayoutState()` rather than this payload: the payload is the live shape, while the serialized form carries a kind and a schema version, and `restoreLayoutState()` can then say why it ignored a blob instead of applying one whose shape no longer fits. Does not fire for the state the table mounted with, only for a real change after that, so a host writing straight through to storage does not immediately re-save what it just loaded via `applyLayoutState`.
selectedIdsChangeSet<string | number>Emits the new `selectedIds` whenever it changes — the output half of `[(selectedIds)]`.
expandedRowIdsChangeSet<string | number>Emits the new `expandedRowIds` whenever it changes — the output half of `[(expandedRowIds)]`.
pageSizeChangenumberEmits the new `pageSize` whenever it changes — the output half of `[(pageSize)]`.
hiddenColumnKeysChangeSet<string>Emits the new `hiddenColumnKeys` whenever it changes — the output half of `[(hiddenColumnKeys)]`.
revealedColumnKeysChangeReadonlySet<string>Emits the new `revealedColumnKeys` whenever it changes — the output half of `[(revealedColumnKeys)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — it just saves you writing the debounce, the request guard and the retry policy by hand.

Template

Bound to the names this component actually exposes.

usage.htmlAngular
<sv-data-table
  [serverSide]="true"
  [rows]="data.items()"
  [loading]="data.loading()"
  [error]="data.error()"
  [totalCount]="data.total()"
  (queryChange)="data.setQuery($event)"
  (retry)="data.reload()"
/>

Host class

One controller handles debounce, request-guarding, cancellation and retry.

host.component.tsTypeScript
import { svDataController, createHttpDataSource } from '@styloviz/data';

readonly data = svDataController(
  createHttpDataSource<Row>({ url: '/api/rows' }),
);
  • `queryChange` carries an `SvDataQuery` — page, page size, sorts, filters, search and cursor in one object. One handler covers every way the user can change what they are asking for.
  • `serverSide` switches **off** the built-in filtering, sorting and paging. Leave it false and the component works entirely from the array you pass — which is what you want for a few hundred rows.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Set `[transparent]="true"` to remove the table's own surface fill (root, toolbar, body and rows) so it inherits the background of a glass card or coloured panel — hover/selection and `striped` tints are preserved and the card shadow is dropped; pair with `variant="borderless"`.
  • Project rich cell content per column with the `svDtCell` directive: `<ng-template svDtCell="colKey" let-row let-value="value">…</ng-template>` (context exposes `$implicit` = row, `value`, `column`) — falls back to `col.render` text or a badge cell when no template is provided.
  • Enable sorting, pagination, selection and bulk actions per table.
  • Pro adds column filters, inline editing and expandable rows.
  • Set `resizableColumns` to drag-resize columns (per-column `width` and `resizable` opt-out) and `reorderableColumns` to drag- or keyboard-reorder them; changes emit `columnResize` and `columnOrderChange`.
  • Set `layout="comparison"` to transpose the table into a Compare-Products grid: the first column supplies each product's name (becoming the column headers) and the remaining columns become feature rows, with boolean values rendered as check / cross icons; `highlightRowId` spotlights the recommended product column.
  • **Feature compatibility.** `virtualized` and `groupBy` each replace client pagination rather than combining with it: both render the whole sorted set into one scroller and the pager is hidden, so a `pageSize` alongside either is ignored.
  • **Feature compatibility.** `serverSide` turns the client pipeline off entirely — filtering, sorting and paging all pass `rows` through untouched, because the server already did them. There is no double filtering, and equally no client-side fallback: whatever you do not implement server-side does not happen.
  • **Feature compatibility.** `serverSide` composes with `groupBy`, but it groups only the page the server sent — a group header reads as a section of this page, not of the dataset.
  • **Feature compatibility.** `footerFn`, export and selection all operate on rows in hand. Client-side that is the filtered set; in `serverSide` mode it is the current page, so a footer "Total" is a page total and a select-all selects the page.
  • **Feature compatibility.** `layout="comparison"` is a separate rendering path: toolbar, filters, pagination, selection, virtualization, grouping and the footer row do not apply to it. Use it for a fixed handful of products, not as a table mode to toggle.
  • **Feature compatibility.** `cardOnMobile` windows on its own `cardRowHeight` under `virtualized`, and deliberately does not group — a collapsible header spanning a card list has no column structure to summarise. Size it to roughly `ceil(visibleColumns / 2) * 46 + 32`px.

Best practices

  • Do: Set a stable `rowKey` so selection and row tracking survive re-renders.
  • Do: Switch to `serverSide` paging once datasets pass a few thousand rows.
  • Don't: Don't render hundreds of unpaged rows — enable `paginated` or virtualize.
  • Do: In `serverSide` mode, guard every response with `acceptsResponse(event.requestId)` before applying it. The table renders what the server sent verbatim, so if a slower request lands second the rows and the header disagree and nothing in your own code looks wrong.
  • Do: Give an `editable` column a `validate` function. It runs on each keystroke and again on commit, so the message shows while the offending text is still on screen, and an invalid value never reaches `editComplete`.
  • Don't: Don't read a `footerFn` total as a dataset total in `serverSide` mode — it sums the page the server sent. The same applies to export and to selection: both act on rows in hand. Ask the server for the aggregate instead.

Accessibility

  • Keyboard at scale (X-10): the free table is paginated by default, so its page size is the ceiling. Under `virtualized` the rows are ONE tab stop across the whole dataset: ↑/↓, Home/End, PageUp/PageDown (one viewport, clamped) and typeahead over the first column move the cursor and scroll the window to it; Enter activates the row. Documented ceiling: 2,000 rows rendered at once with neither pagination nor `virtualized` — past it a dev-mode warning fires.
  • Focus after a mutation (X-12): if the row holding focus leaves the rendered set — a filter it no longer matches, a host refresh, a delete — focus moves to the same control in the row now at its index, then the row before it, then the table's scroller, and a polite announcement says the row is no longer shown.
  • Native table semantics with scoped headers and an accessible table name from `tableTitle`.
  • Sortable headers are focusable and operable with Enter/Space, exposing `aria-sort` state.
  • Resize handles are focusable separators (`role="separator"`) adjustable with ←/→; reorderable columns move from the keyboard with Ctrl/Cmd + ←/→ on a focused header.
  • Selection, pagination and row actions are keyboard reachable; open menus close on Escape.
  • Loading regions set `aria-busy`; row-action menus use `role="menu"`/`menuitem`.
  • Under `virtualized`, the table declares `aria-rowcount` and stamps every rendered row with its true `aria-rowindex`, so a screen reader announces "row 140 of 241" rather than the size of the window. Header rows, the column-filter row, group headers, expanded detail rows and the footer row all take their place in that numbering; the spacer rows are `aria-hidden` and unnumbered. Both attributes are omitted whenever every row is already in the DOM, so the browser's own count stands and cannot be contradicted.
  • A `validate` failure marks the editor `aria-invalid` and points `aria-describedby` at the message, which is also a polite live region — described-by covers arriving at the field, the live region covers an error appearing under a cursor already in it. Enter and blur refuse to commit while it stands; Escape always discards.

Feedback on Data Table

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.