NimUI
ComponentsData Display

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

Basic Table
NameEmailRole
Alice Johnsonalice@example.comAdmin
Bob Smithbob@example.comEditor
Carol Daviscarol@example.comViewer
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>

Use DataTableFooter to add summary rows such as totals or aggregates.

Table with Footer
ProductQtyPrice
Widget A3$30.00
Widget B1$45.00
Total4$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.

Loading DataTable — Toggle the state; the header stays put and the live region stays mounted
Loading shipments
ShipmentCustomerHubStatusItemsTotal
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, holding loadingLabel ("Loading" by default) while loading and loadedLabel — nothing, unless you set it — afterwards. Each Skeleton is aria-hidden, so the placeholder rows themselves stay out of the accessibility tree.
  • The region is a sibling of the aria-busy host, never a descendant. aria-busy tells 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-busy is applied after the prop spread, like Button's: loading is the semantic contract, so aria-busy={false} from a caller cannot report idle while placeholder rows are on screen. When you are not loading, your own aria-busy still applies.
  • The region must not unmount — see the "don't" above.

Props

DataTable

NameTypeDefaultDescription
loadingboolean-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
loadingLabelstring'Loading'Screen-reader text held in the live region while loading
loadedLabelstring-Optional text announced when loading finishes; omitted by default so a refetching dashboard stays quiet
classNamestring-Additional CSS classes to apply to the table element
children*ReactNode-DataTableHeader, DataTableBody, and DataTableFooter components

DataTableHeader

NameTypeDefaultDescription
classNamestring-Additional CSS classes for the thead element
children*ReactNode-DataTableRow components containing DataTableHead cells

DataTableBody

NameTypeDefaultDescription
classNamestring-Additional CSS classes for the tbody element
children*ReactNode-DataTableRow components containing DataTableCell cells

DataTableFooter

NameTypeDefaultDescription
classNamestring-Additional CSS classes for the tfoot element
children*ReactNode-DataTableRow components for footer content

DataTableRow

NameTypeDefaultDescription
classNamestring-Additional CSS classes for the tr element
children*ReactNode-DataTableHead or DataTableCell components

DataTableHead

NameTypeDefaultDescription
classNamestring-Additional CSS classes for the th element
children*ReactNode-Header cell content

DataTableCell

NameTypeDefaultDescription
classNamestring-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>
  );
}
  • DataCard - Metric display card for KPIs
  • Badge - Status indicators for table cells
  • Skeleton - The placeholders that go in the cells while loading
  • CartItem - Shopping cart line items

On this page