The shared ledger table — sortable headers, skeleton loading, an empty state, and optional row actions.
<app-data-table><thead>…</app-data-table>
A data table lays out a list of records in rows and columns — the workhorse of every list screen. <app-data-table> owns the shared ledger look: sortable headers, a skeleton while loading, a graceful empty state, and an optional row-actions column.
Its look comes entirely from the shared @webority/theme, so the Razor <app-data-table> and the React <AppDataTable> render identically — a table is a table on every Webority surface.
In a real screen
An invoices list as a product would build it: 48 rows, a status filter, search, sortable columns, selection, pagination and row actions. Filter, search and paging round-trip through the query string.
Every attribute, live. Each change re-renders the real tag helper on the server, so the markup underneath is the exact call that produced the table above it. <thead>/<tbody> content is server-rendered, so it stays fixed here — gate your own real <tbody> off loading the same way this page does.
columns/rows are server markup, not a propUnlike the React <AppDataTable columns rows>, the Razor tag helper is slot-based — you already have the data on the server, so you write the <thead>/<tbody> yourself. Every attribute above (sortable, loading, selectable, density, striped, max-height, sticky-first-column, stack-on-mobile, aria-label, class) still round-trips through the real tag helper.
Columns & rows
Write the table server-side inside the wrapper — a thead of headers, a tbody of rows — and it inherits the shared ledger styling.
A minimal table
The wrapper supplies the themed table element; you render the rows.
Absent values read as an em dashA cell with no value should render an em dash — "—" — rather than sitting blank. Note 0 and false are real data and render as themselves — only a genuinely missing value becomes the dash.
Custom cells & actions
A cell can hold real UI — a status pill, a formatted date. A trailing column carries per-row controls.
A status pill and row actions
Render a status-label span for state, and a u-action button group for the row's controls.
One table that reaches for most of the library at once — a selectable checkbox column, an avatar identity cell, a status pill, a chip, a progress meter, a switch, an inline copy button, and a kebab menu of row actions. Every cell is a shared App* tag helper, not bespoke markup.
Sorting happens in the browserThe sortable flag sorts the rows already on the page. For a list too large to render at once, page it on the server instead and sort in the query.
Density
Row height is a choice. comfortable (the default) uses 54px rows, cozy tightens them to 48px, and compact goes to 40px with one line per row: the identity subline is hidden and text steps down to 13px. An avatar with no size of its own follows the density. Add striped to swap the divider lines for alternate-row shading; it works with any density.
Three densities
The same rows at comfortable, cozy and compact. The avatars are given no size, so they follow the density.
comfortable
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
KR
Kabir Raokabir@acme.in
Suspended
Free
cozy
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
KR
Kabir Raokabir@acme.in
Suspended
Free
compact
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
KR
Kabir Raokabir@acme.in
Suspended
Free
Show code
<app-data-table>…</app-data-table>
<app-data-table density="cozy">…</app-data-table>
<app-data-table density="compact">…</app-data-table>
<!-- Alternate-row shading instead of divider lines, with any density -->
<app-data-table density="compact" striped="true">…</app-data-table>
Long and wide tables
Give the table a max-height and the body scrolls inside the box while the header stays pinned; nothing sticks to the page itself. Add sticky-first-column to keep the identity column in view (with the select or expand column before it) while a wide table scrolls sideways. Add stack-on-mobile to turn each row into a card on phones instead of scrolling sideways.
Fifteen rows in a 280px box
Scroll the rows and the header stays at the top of the box. Scroll sideways and the Reference column stays put.
Narrow the window below 576px to see each row become a card: the first column is the title, the other cells read as label and value, and the row actions sit at the bottom.
Put data-status on a <tr> and the table draws a coloured bar on its leading edge, so a problem row reads at a glance. Write a <tfoot> under the body for a totals row. React's rowStatus and column footer produce the same markup.
Status bars and a totals row
Two rows carry a status, and the footer counts the requests and sums the budget.
The whole row opens its record. Put data-href on a <tr> and a click or Enter opens that URL; Ctrl, Cmd or a middle click opens a new tab. A link, button or checkbox inside the row keeps its own behaviour, and Enter works on a focused row. React's rowHref produces the same markup, and its onRowClick covers in-page actions.
A clickable row with a checkbox column
Click anywhere on a row to open it. The checkbox is a control inside the row, so ticking it selects without opening. The links here are in-page anchors, so nothing navigates away.
Set loading to render a skeleton body while the data is in flight (gate your real <tbody> off server-side); selectable adds a select-all checkbox column. Always handle the empty case with a clear message row.
Loading skeleton
loading renders a skeleton body of skeleton-rows × skeleton-cols; render your real <tbody> only once the data has arrived.
selectable injects a leading select-all checkbox column; the header toggles every row and shows an indeterminate state when only some are checked. The table fires a selectionchange event.
With no rows, render only the header and put the empty state in a sticky .tbl-empty-block sibling inside .table-wrap — the same shape React's AppDataTable renders. The header scrolls alone; the message stays pinned and centred (never a <td colSpan>, which slid off-screen on mobile).
More columns than the panel is wide? The table scrolls horizontally inside its own region — the page never gets a sideways scrollbar. Populated or empty, only the table scrolls.
A wide ledger with real data
Seven columns, mixed cell types. Drag the header (or the row body) sideways to reach Budget and Last updated.
You are listing records in rows and columns — projects, assessments, members.
The list needs sortable headers, a loading skeleton, or an empty state.
Rows need per-row actions (edit, delete) in a trailing column.
Reach for something else when
You are laying out non-tabular content on a surface. → AppCard
You only need to page through a list, not tabulate it. → AppTablePagination alone
The empty list deserves a rich call to action. → AppTableEmptyState
Best practices
Do
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
Status shown as a pill — scannable at a glance, not raw text.
Don't
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
Status left as plain text blends into the row — the reader has to actually read each one.
Do
Building
No buildings yet.
An explicit empty message tells the user the list is genuinely empty, not broken.
Don't
Building
A blank table with no message reads as a bug — the user can't tell empty from failed.
Every option
The whole surface of the tag helper, one cell per value — column alignment/width/custom cells are plain markup (there is no columns prop on this surface), plus every attribute: loading variants, selectable, sorting, row actions, aria-label, and the wrapper/table class hooks. Row expansion and pagination-as-a-prop have no Razor equivalent — see the callouts below.
No emptyFiltered / sourceCount attributesReact's emptyFiltered + sourceCount pick between two messages automatically. Razor has no data to branch on server-side beyond what you already know — render whichever message row applies with an @if around your <tbody>.
sorting — header states
sortable="true"
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
sortable="false" (default) — inert
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
No sort / onSort attributesReact holds the active { key, dir } and reflects it via aria-sort; Razor's sortable hands the whole thing to webority-ui.js, which sorts and updates aria-sort client-side. There's no server-rendered "active sort" state to demo statically.
selectable — selection states
selectable="true" — none selected
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
selectable="false" (default) — no select column
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
Check some rows to see the indeterminate headerClient-side selection state (some vs. all selected) is wired by webority-ui.js and fires a selectionchange event — check a row above to see the header checkbox go indeterminate, then check the rest to see it fill in.
row actions — label
visually-hidden "Actions" header (write it yourself)
Building
Actions
Corporate Office
labeled header — write "Manage" directly
Building
Manage
Corporate Office
aria-label
aria-label="Buildings"
Building
Corporate Office
wrapper & table class
class="p-0" — retunes .table-wrap padding
Building
Corporate Office
class="audit-table" — extra class on the wrapper
Building
Corporate Office
class lands on .table-wrap, not the <table>Unlike React's separate className (table) / wrapperClassName (wrap div), the Razor tag helper only exposes one class attribute, and it merges onto the outer .table-wrap — the same padding hook React's wrapperClassName targets. To style the <table> element itself, target it from your own CSS via a descendant selector.
density
density="comfortable"
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
density="cozy"
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
density="compact"
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
striped
striped
Member
Status
Plan
AS
Aarav Sharmaaarav@acme.in
Active
Pro
DI
Diya Iyerdiya@acme.in
Invited
Team
KR
Kabir Raokabir@acme.in
Suspended
Free
row expansion
renderExpanded — no Razor attribute
React's renderExpanded owns client-side expand/collapse state per row. Razor is slot-based with no client state to hook a toggle into — the nearest equivalent is a details/summary row you write yourself, or a link to a detail page.
pagination — footer
<app-table-pagination> composed underneath — no pagination attribute
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
Composed, not a prop
Razor has no pagination attribute; place <app-table-pagination> under the table yourself, driven by your own page/total.
max-height="140px": body scrolls, header stays pinned
Building
Status
Created
Corporate Office
Draft
2026-07-10
Warehouse Annexe
Compliant
2026-07-08
Server Room
Protection needed
2026-06-28
Canteen Block
Draft
2026-06-14
Logistics Hub
Compliant
2026-06-02
Rooftop Plant
Protection needed
2026-05-21
stickyFirstColumn
sticky-first-column="true": first column pinned while scrolling sideways
Reference
Project
Status
Owner
Region
Budget (₹)
Last updated
REQ-4821
Northgate Retrofit
In review
Aarav Sharma
West
12,40,000
2026-08-14
REQ-4820
Harbour Cold Store
Approved
Diya Iyer
South
8,10,000
2026-08-12
stackOnMobile
stack-on-mobile="true": rows become cards below 576px
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
rowStatus
data-status="danger" on <tr>
Building
Status
Created
Corporate Office
Draft
2026-07-10
Warehouse Annexe
Compliant
2026-07-08
data-status="warning" on <tr>
Building
Status
Created
Corporate Office
Draft
2026-07-10
Warehouse Annexe
Compliant
2026-07-08
rowHref
data-href="#open-1" on <tr>
Building
Status
Created
Corporate Office
Draft
2026-07-10
Warehouse Annexe
Compliant
2026-07-08
footer
<tfoot> you write, totals row
Building
Status
Corporate Office
Draft
Warehouse Annexe
Compliant
2 buildings
Attributes
Attribute
Type
Default
Description
sortable
bool
false
Opts into client-side column sorting (webority-ui.js). React equivalent: sort/onSort.
loading
bool
false
Renders a skeleton body instead of rows — gate your real <tbody> off while loading.
skeleton-rows
int
5
Number of skeleton rows shown while loading. React equivalent: skeletonRows.
skeleton-cols
int
4
Number of skeleton columns shown while loading. React has no equivalent — it infers the column count from columns.
selectable
bool
false
Adds a leading select-all checkbox column (fires a selectionchange event). React equivalent: selectable/selectedIds/onToggleRow/onToggleAll.
aria-label
string
—
Accessible name for the table. React equivalent: ariaLabel.
class
string
—
Extra classes, merged onto the outer .table-wrap (the padding hook). React equivalent: wrapperClassName — React additionally has a separate className for the <table> itself, which Razor has no attribute for.
density
'comfortable' | 'cozy' | 'compact'
comfortable
Row height: 54, 48 or 40px. Compact keeps one line per row (identity subline hidden, 13px text). An avatar with no size of its own follows the density.
striped
boolean
false
Alternate-row shading in place of divider lines. Works with any density.
max-height
string
-
Caps the table's height. The body scrolls inside the box and the header stays pinned; there is no page-level sticking. A bare number reads as pixels. React equivalent: maxHeight.
sticky-first-column
bool
false
Pins the first column, and the select or expand column before it, while a wide table scrolls sideways. React equivalent: stickyFirstColumn.
stack-on-mobile
bool
false
Below 576px each row becomes a card: the first column is the title, the other cells read as label and value, the select checkbox moves to the top corner, row actions sit at the bottom and the status bar becomes the card's left border. A max-height scroll box and pinned columns are dropped on phones. React equivalent: stackOnMobile.
(content) *
markup
—
The server-rendered table body — write the <thead> and <tbody> inside. React equivalent: columns + rows + rowKey.
Row status and totals are markup, not attributesReact's rowStatus and column footer are written by hand here: put data-status="success|warning|danger|info" on a <tr> for the leading-edge bar, and add a <tfoot> under the body for a totals row (webority-ui.js adds the empty select cell to <tfoot> rows on a selectable table).
Clickable rows are markup, not an attributeReact's rowHref is written by hand here: put data-href="url" on a <tr> and webority-ui.js makes the whole row open it on click or Enter (Ctrl, Cmd or a middle click opens a new tab), adding the pointer cursor and keyboard focus itself. A link, button, input or menu inside the row keeps its own behaviour. React's onRowClick has no Razor equivalent.
React-only: sort, onSort, empty, emptyFiltered, sourceCount, rowActions, rowActionsLabel, pagination, selectedIds, onToggleRow, onToggleAll, renderExpanded, classNameThese are all reached the idiomatic server-rendered way instead — sort state via sortable + client JS, empty/actions/row-expansion as markup you write, pagination by composing <app-table-pagination> underneath. See the callouts in "Every option" above for the specifics.