DataTable
Semantic HTML table components for displaying structured tabular data
The DataTable component provides a set of composable, semantic HTML table primitives for displaying structured data. Each sub-component maps to a native table element with consistent styling, hover states, and dark mode support.
Import
import {
DataTable,
DataTableHeader,
DataTableBody,
DataTableFooter,
DataTableRow,
DataTableHead,
DataTableCell,
// For the loading pattern below — one Skeleton per DataTableCell.
Skeleton,
} from '@nim-ui/components';Basic Usage
| Name | Role | |
|---|---|---|
| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol Davis | carol@example.com | Viewer |
Code
<DataTable>
<DataTableHeader>
<DataTableRow>
<DataTableHead>Name</DataTableHead>
<DataTableHead>Email</DataTableHead>
<DataTableHead>Role</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<DataTableRow>
<DataTableCell>Alice Johnson</DataTableCell>
<DataTableCell>alice@example.com</DataTableCell>
<DataTableCell>Admin</DataTableCell>
</DataTableRow>
<DataTableRow>
<DataTableCell>Bob Smith</DataTableCell>
<DataTableCell>bob@example.com</DataTableCell>
<DataTableCell>Editor</DataTableCell>
</DataTableRow>
<DataTableRow>
<DataTableCell>Carol Davis</DataTableCell>
<DataTableCell>carol@example.com</DataTableCell>
<DataTableCell>Viewer</DataTableCell>
</DataTableRow>
</DataTableBody>
</DataTable>With Footer
Use DataTableFooter to add summary rows such as totals or aggregates.
| Product | Qty | Price |
|---|---|---|
| Widget A | 3 | $30.00 |
| Widget B | 1 | $45.00 |
| Total | 4 | $75.00 |
Code
<DataTable>
<DataTableHeader>
<DataTableRow>
<DataTableHead>Product</DataTableHead>
<DataTableHead>Qty</DataTableHead>
<DataTableHead>Price</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<DataTableRow>
<DataTableCell>Widget A</DataTableCell>
<DataTableCell>3</DataTableCell>
<DataTableCell>$30.00</DataTableCell>
</DataTableRow>
<DataTableRow>
<DataTableCell>Widget B</DataTableCell>
<DataTableCell>1</DataTableCell>
<DataTableCell>$45.00</DataTableCell>
</DataTableRow>
</DataTableBody>
<DataTableFooter>
<DataTableRow>
<DataTableCell>Total</DataTableCell>
<DataTableCell>4</DataTableCell>
<DataTableCell>$75.00</DataTableCell>
</DataTableRow>
</DataTableFooter>
</DataTable>Loading
A loading data table is the most common skeleton surface in a dashboard, and the one place Skeleton needs help to go: Skeleton renders a <div>, and a <div> is not valid content for <tbody> or <tr>. The HTML parser does not merely tolerate that — it foster-parents the element out of the table and inserts it just above, so the placeholder renders in the wrong place and server-rendered markup fails to hydrate.
A <td> takes flow content, so the fix is simply to keep every placeholder in a cell: <DataTableCell><Skeleton className="h-4 w-32" /></DataTableCell>.
DataTable splits the work. It owns the parts a consumer cannot get right by hand — one role="status" live region and aria-busy on the table — and you own the rows, because DataTable has no columns prop and never introspects its children, so it cannot know how many placeholder cells a row needs. Writing them out is also what buys per-column widths, which is the difference between a placeholder that reads as the table and one that reads as noise.
| Shipment | Customer | Hub | Status | Items | Total |
|---|---|---|---|---|---|
Code
function ShipmentsTable({ shipments, isLoading }: Props) {
return (
<DataTable loading={isLoading} loadingLabel="Loading shipments" aria-label="Shipments">
<DataTableHeader>
<DataTableRow>
<DataTableHead>Shipment</DataTableHead>
<DataTableHead>Customer</DataTableHead>
<DataTableHead className="text-right">Total</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
{isLoading
? [0, 1, 2].map((row) => (
<DataTableRow key={row}>
<DataTableCell>
<Skeleton className="h-4 w-24" />
</DataTableCell>
<DataTableCell>
<Skeleton className="h-4 w-36" />
</DataTableCell>
<DataTableCell>
<Skeleton className="ml-auto h-4 w-20" />
</DataTableCell>
</DataTableRow>
))
: shipments.map((shipment) => (
<DataTableRow key={shipment.id}>
<DataTableCell>{shipment.id}</DataTableCell>
<DataTableCell>{shipment.customer}</DataTableCell>
<DataTableCell className="text-right">{shipment.total}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
);
}Omit loading entirely and nothing changes: no live region is rendered and no aria-busy is written. Passing loading={false} opts in — the region mounts empty, which is what makes the later text change an announcement rather than an insertion.
Don't: swap the table out for a spinner
The obvious shape, and the one that quietly undoes the whole thing. The live region lives inside DataTable, so unmounting the table unmounts the region — a region created at the same moment its text arrives is announced inconsistently by screen readers, and it disappears again the instant loading ends. Because the region is invisible in your code, there is no visual cue that it broke.
It also throws away the <thead>: the column labels vanish, the layout collapses to whatever the spinner is, and every column width is re-derived from scratch when the real rows arrive.
// ✗ Don't — the live region is mounted and unmounted with the table
{isLoading ? <Spinner /> : <DataTable>{/* … */}</DataTable>}
// ✗ Don't — same defect, and SkeletonGroup cannot go inside a table anyway
{isLoading && <SkeletonGroup loading fallback={<Skeleton className="h-40 w-full" />} />}Do: mount the table unconditionally and flip loading
The only thing that should change between states is the children of <DataTableBody>. The header, the table element, the scroller and the live region all stay exactly where they are.
// ✓ Do — one table, one region, mounted the whole time
<DataTable loading={isLoading}>
<DataTableHeader>{/* real column labels, both states */}</DataTableHeader>
<DataTableBody>{isLoading ? skeletonRows : dataRows}</DataTableBody>
</DataTable>Accessibility
Loading a table is a status message in the WCAG 2.2 sense — a change of state the user has to learn about without moving focus (SC 4.1.3 Status Messages, AA). DataTable handles it the same way SkeletonGroup does:
- One
role="status"region per table, holdingloadingLabel("Loading" by default) while loading andloadedLabel— nothing, unless you set it — afterwards. EachSkeletonisaria-hidden, so the placeholder rows themselves stay out of the accessibility tree. - The region is a sibling of the
aria-busyhost, never a descendant.aria-busytells assistive tech to defer announcements for its own subtree, which is exactly the window the region needs to speak in. Both sit inside the table's existing scroller, outside the table's content model. aria-busyis applied after the prop spread, likeButton's:loadingis the semantic contract, soaria-busy={false}from a caller cannot report idle while placeholder rows are on screen. When you are not loading, your ownaria-busystill applies.- The region must not unmount — see the "don't" above.
Props
DataTable
| Name | Type | Default | Description |
|---|---|---|---|
loading | boolean | - | Whether the table body is still loading. Omit it entirely to opt out — no live region and no aria-busy are rendered. You render the skeleton rows |
loadingLabel | string | 'Loading' | Screen-reader text held in the live region while loading |
loadedLabel | string | - | Optional text announced when loading finishes; omitted by default so a refetching dashboard stays quiet |
className | string | - | Additional CSS classes to apply to the table element |
children* | ReactNode | - | DataTableHeader, DataTableBody, and DataTableFooter components |
DataTableHeader
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the thead element |
children* | ReactNode | - | DataTableRow components containing DataTableHead cells |
DataTableBody
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the tbody element |
children* | ReactNode | - | DataTableRow components containing DataTableCell cells |
DataTableFooter
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the tfoot element |
children* | ReactNode | - | DataTableRow components for footer content |
DataTableRow
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the tr element |
children* | ReactNode | - | DataTableHead or DataTableCell components |
DataTableHead
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the th element |
children* | ReactNode | - | Header cell content |
DataTableCell
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the td element |
children* | ReactNode | - | Cell content |
Usage Examples
Dynamic Data Rendering
interface User {
id: string;
name: string;
email: string;
role: string;
}
function UsersTable({ users }: { users: User[] }) {
return (
<DataTable>
<DataTableHeader>
<DataTableRow>
<DataTableHead>Name</DataTableHead>
<DataTableHead>Email</DataTableHead>
<DataTableHead>Role</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
{users.map((user) => (
<DataTableRow key={user.id}>
<DataTableCell>{user.name}</DataTableCell>
<DataTableCell>{user.email}</DataTableCell>
<DataTableCell>{user.role}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
);
}Order Summary Table
function OrderSummary({ items, total }: { items: OrderItem[]; total: string }) {
return (
<DataTable>
<DataTableHeader>
<DataTableRow>
<DataTableHead>Item</DataTableHead>
<DataTableHead>Qty</DataTableHead>
<DataTableHead>Price</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
{items.map((item) => (
<DataTableRow key={item.id}>
<DataTableCell>{item.name}</DataTableCell>
<DataTableCell>{item.quantity}</DataTableCell>
<DataTableCell>{item.price}</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
<DataTableFooter>
<DataTableRow>
<DataTableCell colSpan={2}>Total</DataTableCell>
<DataTableCell>{total}</DataTableCell>
</DataTableRow>
</DataTableFooter>
</DataTable>
);
}