DataTable
RzDataTable<TItem> is an SSR-first shell that serializes Items, Config, and RowIdSelector metadata into an inert JSON script, then lets the Alpine runtime named rzDataTable create a TanStack Table instance for consumer-authored table markup. You provide the semantic table composition through ChildContent, usually with RzTable, table subcomponents, Alpine loops, and x-flexrender for TanStack header, cell, and footer renderers.
Under the hood
The component renders a server-side shell with data-slot="datatable", an Alpine host using x-data="rzDataTable", a lazy-load directive from LoadStrategy, and data-config-id that points to the JSON configuration script. TanStack Table remains the source of truth for sorting, filtering, pagination, row selection, and column visibility; Alpine exposes synchronized projections such as headerGroups, rows, footerGroups, and helper APIs.
Accessibility mode decision
RzDataTable<TItem> is documented as an enhanced semantic table-composition component, not a full ARIA grid. Read the DataTable accessibility mode design note for the current behavior inventory, preservation rules, future file targets, and test categories.
SSR and CSP note
The shell is server rendered and does not use Blazor event callbacks for browser interaction. The examples intentionally use inline Alpine expressions over TanStack objects for readability and target the standard rizzyui runtime; CSP deployments using rizzyui-csp must move those expressions into approved CSP-safe Alpine APIs before shipping.
Basic usage
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Empty state
Empty state is consumer-authored and driven by isEmpty.
<template x-if="isEmpty">
<TableRow>
<TableCell colspan="100" class="text-center">
No results found.
</TableCell>
</TableRow>
</template>Global filter
| No results found. | |
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<label for="datatable-filter" class="text-sm font-medium">Filter users</label>
<input id="datatable-filter" class="border px-2 py-1 rounded" placeholder="Search name" x-on:input="filter.setGlobalFilter($event.target.value)" />
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-if="isEmpty">
<TableRow>
<TableCell colspan="2" class="text-center">No results found.</TableCell>
</TableRow>
</template>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Pagination with previous and next
Page of
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
Page <span x-text="pagination.pageIndex + 1"></span> of <span x-text="pagination.pageCount"></span>
</p>
<RzPagination x-show="pagination.pageCount > 1">
<PaginationList>
<PaginationItem>
<PaginationPrevious Href="#"
x-on:click.prevent="pagination.previousPage()"
x-bind:aria-disabled="!pagination.canPreviousPage"
x-bind:tabindex="pagination.canPreviousPage ? null : -1"
x-bind:class="!pagination.canPreviousPage ? 'pointer-events-none opacity-50' : ''" />
</PaginationItem>
<PaginationItem>
<PaginationNext Href="#"
x-on:click.prevent="pagination.nextPage()"
x-bind:aria-disabled="!pagination.canNextPage"
x-bind:tabindex="pagination.canNextPage ? null : -1"
x-bind:class="!pagination.canNextPage ? 'pointer-events-none opacity-50' : ''" />
</PaginationItem>
</PaginationList>
</RzPagination>
</div>
</RzDataTable>Pagination with page links
Showing to of results
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted-foreground">
Showing <span x-text="pagination.startRow"></span> to <span x-text="pagination.endRow"></span> of <span x-text="pagination.totalRows"></span> results
</p>
<RzPagination x-show="pagination.pageCount > 1">
<PaginationList>
<PaginationItem>
<PaginationPrevious Href="#"
x-on:click.prevent="pagination.previousPage()"
x-bind:aria-disabled="!pagination.canPreviousPage"
x-bind:tabindex="pagination.canPreviousPage ? null : -1"
x-bind:class="!pagination.canPreviousPage ? 'pointer-events-none opacity-50' : ''" />
</PaginationItem>
<template x-for="item in pagination.items" :key="item.id">
<PaginationItem>
<template x-if="item.kind === 'page'">
<PaginationLink Href="#"
x-text="item.label"
x-on:click.prevent="pagination.setPageIndex(item.pageIndex)"
x-bind:aria-current="item.isActive ? 'page' : null"
x-bind:class="item.isActive ? 'pointer-events-none bg-accent text-accent-foreground' : ''" />
</template>
<template x-if="item.kind === 'ellipsis'">
<PaginationEllipsis />
</template>
</PaginationItem>
</template>
<PaginationItem>
<PaginationNext Href="#"
x-on:click.prevent="pagination.nextPage()"
x-bind:aria-disabled="!pagination.canNextPage"
x-bind:tabindex="pagination.canNextPage ? null : -1"
x-bind:class="!pagination.canNextPage ? 'pointer-events-none opacity-50' : ''" />
</PaginationItem>
</PaginationList>
</RzPagination>
</div>
</RzDataTable>Column visibility
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<div class="flex flex-col gap-2">
<label class="inline-flex items-center gap-2 text-sm font-medium">
<input type="checkbox"
x-bind:checked="columns.getIsAllColumnsVisible()"
x-effect="$el.indeterminate = columns.getIsSomeColumnsVisible() && !columns.getIsAllColumnsVisible()"
x-on:change="columns.toggleAllColumnsVisible($event.target.checked)" />
Toggle all columns
</label>
<div class="flex flex-wrap items-center gap-3">
<template x-for="column in columns.leaf" :key="column.id">
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox"
x-bind:checked="columns.getIsVisible(column)"
x-bind:disabled="!columns.getCanHide(column)"
x-on:change="columns.toggleVisibility(column, $event.target.checked)" />
<span x-text="column.id"></span>
</label>
</template>
</div>
</div>
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Sorting
DataTableSortIcon supports custom icons via AscendingIcon, DescendingIcon, and UnsortedIcon.
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan"
x-bind:aria-sort="sort.ariaSort(header)">
<template x-if="!header.isPlaceholder">
<div class="inline-flex items-center gap-2">
<span x-flexrender="_flex.header(header)"></span>
<DataTableSortToggle HeaderExpr="header" RenderLabel="false" />
</div>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Row selection
Selected rows:
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<p class="text-sm text-muted-foreground">
Selected rows: <span x-text="selectedRowCount"></span>
</p>
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<TableHeaderCell class="w-10 text-center">
<DataTableSelectAllCheckbox Scope="DataTableSelectionScope.Page" />
</TableHeaderCell>
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan">
<template x-if="!header.isPlaceholder">
<span x-flexrender="_flex.header(header)"></span>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<TableCell class="w-10 text-center">
<DataTableRowSelectCheckbox RowExpr="row.row" />
</TableCell>
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Row selection column
<RzDataTable Items="_users" Config="_config" RowIdSelector="x => x.Id">
<RzTable Border>
<TableHeader>
<template x-for="headerGroup in headerGroups" :key="headerGroup.id">
<TableRow>
<DataTableSelectionHeaderCell Scope="DataTableSelectionScope.Page" />
<template x-for="header in headerGroup.headers" :key="header.id">
<TableHeaderCell x-bind:colspan="header.colSpan"
x-bind:aria-sort="sort.ariaSort(header)">
<template x-if="!header.isPlaceholder">
<div class="inline-flex items-center gap-2">
<span x-flexrender="_flex.header(header)"></span>
<DataTableSortToggle HeaderExpr="header" RenderLabel="false" />
</div>
</template>
</TableHeaderCell>
</template>
</TableRow>
</template>
</TableHeader>
<TableBody>
<template x-for="row in rows" :key="row.id">
<TableRow>
<DataTableSelectionCell RowExpr="row.row" />
<template x-for="cell in row.cells" :key="cell.id">
<TableCell x-flexrender="_flex.cell(cell)"></TableCell>
</template>
</TableRow>
</template>
</TableBody>
</RzTable>
</RzDataTable>Architecture and author responsibilities
RzDataTable<TItem> owns data transport and TanStack state; it does not generate the visible table, rows, or cells for you. Author the table in ChildContent and bind it to the runtime projections.
- Use
RzTable,TableHeader,TableBody,TableRow,TableHeaderCell, andTableCellwhen you want native table semantics. - Loop over
headerGroups,rows, andfooterGroupswith Alpinetemplate x-forblocks and stable keys. - Render TanStack content with
x-flexrender="_flex.header(header)",x-flexrender="_flex.cell(cell)", orx-flexrender="_flex.footer(footer)"on the authored element that should receive the rendered content. - Keep
RowIdSelectorstable and unique because the server and runtime both reject duplicate or empty row ids. - Add accessible labels, captions, filter labels, pagination labels, and current/disabled states in your authored markup when the helper components do not provide them automatically.
Accessibility
DataTable follows an enhanced semantic table-composition model. It preserves native table semantics from the authored markup and augments focused controls for sorting, selection, filtering, pagination, and column visibility. It intentionally does not implement the WAI-ARIA grid pattern.
Semantic table composition
- The DataTable root is a neutral shell; it does not add
role="grid", generated row roles, generated cell roles, or an active-descendant model. - When you use
RzTableand table subcomponents, the rendered structure remains a realtablewith nativethead,tbody,tr,th,td,caption, andtfootelements. - Sortable headers should keep
TableHeaderCellsemantics and bindx-bind:aria-sort="sort.ariaSort(header)"on the header cell that represents the sorted column. - Selection should use native checkbox inputs through
DataTableSelectAllCheckbox,DataTableRowSelectCheckbox,DataTableSelectionHeaderCell, orDataTableSelectionCell.
Sorting, selection, pagination, filtering, and visibility
- Sorting:
DataTableSortTogglerenders a nativebutton, binds disabled state to!sort.can(header), binds its accessible name tosort.nextLabel(header), exposesdata-sort-direction, and callssort.toggle(header). - Selection: selection helpers render native checkboxes with
aria-label, checked/disabled bindings, indeterminate state effects, and change handlers that update TanStack row selection state. - Pagination: the runtime exposes page state and methods. Examples bind unavailable links with
aria-disabled, remove them from the tab order withtabindex="-1", and mark the active page witharia-current="page". - Filtering: the runtime exposes
filter.globalFilterandfilter.setGlobalFilter(value). Authors must provide the visible label or accessible name for filter inputs. - Column visibility: the runtime exposes
columns.*state and helpers. Authors must label visibility toggles and preserve checkbox semantics.
Keyboard Interaction
- Tab and Shift + Tab move through authored focusable controls in DOM order, such as sort buttons, selection checkboxes, filter inputs, column visibility checkboxes, and pagination links.
- Enter or Space activates native sort buttons rendered by
DataTableSortToggle. - Space toggles native selection and visibility checkboxes.
- Enter activates pagination links when the authored link is enabled; disabled examples use
aria-disabled="true"andtabindex="-1". - Arrow-key cell navigation, Home/End grid navigation, Page Up/Page Down grid navigation, roving
tabindex, andaria-activedescendantare not implemented byRzDataTable<TItem>.
Focus Management
DataTable does not move focus after sorting, filtering, pagination, selection, or column visibility changes. Focus remains with the native control that the user operated unless your authored markup or browser behavior changes it.
- No focus trap is created.
- No initial focus target is assigned by the DataTable shell.
- No focus restoration is attempted after state changes.
- Because authors own
ChildContent, authors also own any additional focus behavior introduced by custom controls.
Screen Reader Behavior
The DataTable shell includes a visually hidden status region with role="status", aria-live="polite", and aria-atomic="true". The runtime updates it only when a relevant state slice actually changes.
- Sorting changes announce summaries such as an active ascending or descending sort, or that sorting has been cleared.
- Pagination changes announce the page number, page count, visible row range, and total rows.
- Filtering changes announce whether filters are applied and the current matching-row count.
- Selection changes announce the selected-row count.
- Column visibility changes announce how many columns are visible.
- Announcements are polite, not assertive, and repeated identical messages are suppressed.
- The runtime also dispatches
rz:datatable:announcementwith serializable detail containingmessage,politeness, andreason.
SSR/CSP Behavior
- The server output contains the shell,
data-config-id, and an inertscript type="application/json"with serialized transport data. - The Alpine runtime
rzDataTablereads that config by id, creates the TanStack Table instance, and dispatches browserrz:custom events. - No Blazor
EventCallback,@onclick,@onchange,@bind, or interactive circuit is required for DataTable behavior. - The inline Alpine expressions shown in these examples are documentation examples for the standard runtime. For strict CSP usage, keep the same runtime contract but move expressions into CSP-safe Alpine factories or helpers.
Known Limitations
RzDataTable<TItem>is not an ARIA grid and should not be documented or consumed as one.- Grid-style cell navigation, roving focus, active-descendant focus, row/column count ARIA, and cell-level selection semantics are not provided.
- The shell cannot verify every possible
ChildContentcomposition, so authors must preserve semantic table structure, labels, captions, and relationships in custom markup. - Generated
rowSelectioncell renderers produce checkbox HTML for convenience; use the Razor selection helper components when you need more control over surrounding table semantics or labels. - The runtime is client-side TanStack state only. It does not perform server paging, server filtering, or server sorting by itself.
Component parameters
Configure RzDataTable<TItem> with row data, a stable row id selector, and a TanStack-oriented configuration object.
| Property | Description | Type | Default |
|---|---|---|---|
Items | Required source rows serialized into the DataTable transport. | IReadOnlyList<TItem> | Array.Empty<TItem>() |
Config | Required table configuration containing Columns, Features, and InitialState. | RzDataTableConfig<TItem> | required |
RowIdSelector | Required member-access expression used to derive stable row ids. Values must be non-empty and unique. | Expression<Func<TItem, object?>> | required |
ChildContent | Author-provided markup that renders headers, rows, cells, controls, empty states, and pagination from Alpine projections. | RenderFragment? | null |
LoadStrategy | Inherited Alpine loading strategy rendered as x-load when non-empty. | string | "eager" |
Alpine API
Rendering state: headerGroups, rows, footerGroups, hasRows, isEmpty, and selectedRowCount.
Advanced/internal: table (escape hatch), _stateVersion, and _flex.
Sorting API (sort.*)
sort.*)sort.can(header)— whether a header can sort. [Ref]sort.direction(header)— current sort direction for the header.sort.toggle(header)— toggles sorting for the header. [Ref]sort.ariaSort(header)— mapped ARIA value from current sort state.sort.nextLabel(header)— convenience label for the next sort action.
Selection API (selection.*)
selection.*)selection.canSelect(row)/selection.isSelected(row)/selection.isSomeSelected(row)— row-level selection queries. [Ref]selection.setRowSelected(row, value)— set row selection state. [Ref]selection.setRowSelectedById(rowId, value)— set row selection from a stable serialized row id, used by generatedrowSelectioncells.selection.allPageRowsSelected(),selection.somePageRowsSelected(),selection.setAllPageRows(value)— page-scoped selection helpers. [Ref]selection.allRowsSelected(),selection.someRowsSelected(),selection.setAllRows(value)— table-wide selection helpers. [Ref]
Pagination API (pagination.*)
pagination.*)- State:
pagination.pageIndex,pagination.pageSize,pagination.pageCount,pagination.canPreviousPage,pagination.canNextPage,pagination.totalRows,pagination.startRow,pagination.endRow,pagination.items. - Actions:
pagination.previousPage(),pagination.nextPage(),pagination.firstPage(),pagination.lastPage(),pagination.setPageIndex(index),pagination.setPageSize(size). [Ref]
Filtering API (filter.*)
filter.*)filter.globalFilter— reactive mirror of global filter state. [Ref]filter.setGlobalFilter(value)— sets global filter text. [Ref]
Columns API (columns.*)
columns.*)- Reactive state:
columns.all,columns.leaf,columns.visibleLeaf,columns.columnVisibility. [Ref] - Table-level methods:
columns.getColumn(id),columns.getAllColumns(),columns.getAllLeafColumns(),columns.getVisibleLeafColumns(),columns.setColumnVisibility(updater),columns.resetColumnVisibility(),columns.toggleAllColumnsVisible(value),columns.getIsAllColumnsVisible(),columns.getIsSomeColumnsVisible(). [Ref] - Column-level methods:
columns.getCanHide(column),columns.getIsVisible(column),columns.toggleVisibility(column, value). [Ref]
Event names
rz:datatable:readyrz:datatable:state-changedrz:datatable:selection-changedrz:datatable:row-selection-input-changedrz:datatable:page-changedrz:datatable:sort-changedrz:datatable:filter-changedrz:datatable:column-visibility-changedrz:datatable:announcement
The aggregate state event fires after any TanStack state update. Granular events fire only when their state slice changes, and payloads use serializable primitives such as component ids, row ids, selected row ids, pagination objects, sorting descriptors, filter values, and column ids.