RizzyUI

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.

Basic usage

Source
<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.

Source
<template x-if="isEmpty">
    <TableRow>
        <TableCell colspan="100" class="text-center">
            No results found.
        </TableCell>
    </TableRow>
</template>

Global filter

Source
<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

Source
<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

Source
<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

Source
<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.

Source
<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:

Source
<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

Source
<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, and TableCell when you want native table semantics.
  • Loop over headerGroups, rows, and footerGroups with Alpine template x-for blocks and stable keys.
  • Render TanStack content with x-flexrender="_flex.header(header)", x-flexrender="_flex.cell(cell)", or x-flexrender="_flex.footer(footer)" on the authored element that should receive the rendered content.
  • Keep RowIdSelector stable 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 RzTable and table subcomponents, the rendered structure remains a real table with native thead, tbody, tr, th, td, caption, and tfoot elements.
  • Sortable headers should keep TableHeaderCell semantics and bind x-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, or DataTableSelectionCell.

Sorting, selection, pagination, filtering, and visibility

  • Sorting: DataTableSortToggle renders a native button, binds disabled state to !sort.can(header), binds its accessible name to sort.nextLabel(header), exposes data-sort-direction, and calls sort.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 with tabindex="-1", and mark the active page with aria-current="page".
  • Filtering: the runtime exposes filter.globalFilter and filter.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" and tabindex="-1".
  • Arrow-key cell navigation, Home/End grid navigation, Page Up/Page Down grid navigation, roving tabindex, and aria-activedescendant are not implemented by RzDataTable<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:announcement with serializable detail containing message, politeness, and reason.

SSR/CSP Behavior

  • The server output contains the shell, data-config-id, and an inert script type="application/json" with serialized transport data.
  • The Alpine runtime rzDataTable reads that config by id, creates the TanStack Table instance, and dispatches browser rz: 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 ChildContent composition, so authors must preserve semantic table structure, labels, captions, and relationships in custom markup.
  • Generated rowSelection cell 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.

PropertyDescriptionTypeDefault
ItemsRequired source rows serialized into the DataTable transport.IReadOnlyList<TItem>Array.Empty<TItem>()
ConfigRequired table configuration containing Columns, Features, and InitialState.RzDataTableConfig<TItem>required
RowIdSelectorRequired member-access expression used to derive stable row ids. Values must be non-empty and unique.Expression<Func<TItem, object?>>required
ChildContentAuthor-provided markup that renders headers, rows, cells, controls, empty states, and pagination from Alpine projections.RenderFragment?null
LoadStrategyInherited 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.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.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 generated rowSelection cells.
  • 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.*)

  • 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.globalFilter — reactive mirror of global filter state. [Ref]
  • filter.setGlobalFilter(value) — sets global filter text. [Ref]

Columns API (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:ready
  • rz:datatable:state-changed
  • rz:datatable:selection-changed
  • rz:datatable:row-selection-input-changed
  • rz:datatable:page-changed
  • rz:datatable:sort-changed
  • rz:datatable:filter-changed
  • rz:datatable:column-visibility-changed
  • rz: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.