# Native Table

Display tabular content, such as a report, in a native HTML table where each control in a cell keeps its own tab stop.

Invoices

[Complete TSX file](/examples/native-table/default.tsx)

```tsx
"use client";

import { Badge } from "@/siteplane/components/badge";
import {
  NativeTable,
  NativeTableBody,
  NativeTableCell,
  NativeTableHead,
  NativeTableHeader,
  NativeTableRow,
} from "@/siteplane/components/native-table";

const statusVariants = {
  Draft: "secondary",
  Open: "info",
  Overdue: "error",
  Paid: "success",
} as const;

const invoices = [
  {
    id: "INV-2041",
    customer: "Brightline Labs",
    status: "Paid",
    issued: "Sep 2, 2026",
    amount: "$4,800.00",
  },
  {
    id: "INV-2042",
    customer: "Harbor & Pine",
    status: "Overdue",
    issued: "Sep 15, 2026",
    amount: "$1,250.00",
  },
  {
    id: "INV-2043",
    customer: "Northfield Health",
    status: "Open",
    issued: "Oct 1, 2026",
    amount: "$12,400.00",
  },
  {
    id: "INV-2044",
    customer: "Quanta Logistics",
    status: "Paid",
    issued: "Oct 14, 2026",
    amount: "$860.00",
  },
  {
    id: "INV-2045",
    customer: "Sora Studio",
    status: "Draft",
    issued: "Oct 27, 2026",
    amount: "$3,175.00",
  },
] as const;

export default function Example() {
  return (
    <NativeTable aria-label="Invoices" minWidth={560}>
      <NativeTableHeader>
        <NativeTableRow>
          <NativeTableHead>Invoice</NativeTableHead>
          <NativeTableHead>Customer</NativeTableHead>
          <NativeTableHead>Status</NativeTableHead>
          <NativeTableHead>Issued</NativeTableHead>
          <NativeTableHead align="end">Amount</NativeTableHead>
        </NativeTableRow>
      </NativeTableHeader>
      <NativeTableBody>
        {invoices.map((invoice) => (
          <NativeTableRow key={invoice.id}>
            <NativeTableHead scope="row">{invoice.id}</NativeTableHead>
            <NativeTableCell>{invoice.customer}</NativeTableCell>
            <NativeTableCell>
              <Badge variant={statusVariants[invoice.status]}>
                {invoice.status}
              </Badge>
            </NativeTableCell>
            <NativeTableCell>{invoice.issued}</NativeTableCell>
            <NativeTableCell align="end">{invoice.amount}</NativeTableCell>
          </NativeTableRow>
        ))}
      </NativeTableBody>
    </NativeTable>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/native-table
```

With npm: `npx shadcn@4.18.0 add @siteplane/native-table`. [Registry JSON](/registry/native-table.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/badge @siteplane/button @siteplane/card @siteplane/input @siteplane/popover
```

Native Table uses the stylesheet of [Table](/docs/components/table/), so installing it also installs the Table item and `react-aria-components`.

## Usage

```tsx
import {
  NativeTable,
  NativeTableBody,
  NativeTableCell,
  NativeTableHead,
  NativeTableHeader,
  NativeTableRow,
} from "@/siteplane/components/native-table";

<NativeTable aria-label="Invoices">
  <NativeTableHeader>
    <NativeTableRow>
      <NativeTableHead>Invoice</NativeTableHead>
      <NativeTableHead align="end">Amount</NativeTableHead>
    </NativeTableRow>
  </NativeTableHeader>
  <NativeTableBody>
    <NativeTableRow>
      <NativeTableHead scope="row">INV-2041</NativeTableHead>
      <NativeTableCell align="end">$4,800.00</NativeTableCell>
    </NativeTableRow>
  </NativeTableBody>
</NativeTable>;
```

## Native Table or Table

Native Table shares the stylesheet of [Table](/docs/components/table/), so its heading row, row lines, cell spacing and footer look the same. The two differ in behavior, so choose by what people do with the rows.

Use Native Table for content that people read, such as a report, a price list, or a table in rendered markdown or in an email that your application displays. It renders the browser’s own table elements and adds no behavior to them, and assistive technology presents it as a table. A control in one of its cells, such as an input or a button, stays an ordinary control with its own tab stop. Tab reaches the controls in reading order, and the arrow keys stay with the control in focus, where they move the caret of an input.

Rows that people select, sort or act on belong in Table, a React Aria grid. The grid is a single tab stop: the arrow keys move through its rows and cells, and Tab leaves the table.

## Composition

```text
NativeTable
├── NativeTableCaption (optional, first)
├── NativeTableHeader
│   └── NativeTableRow
│       └── NativeTableHead
├── NativeTableBody
│   └── NativeTableRow
│       ├── NativeTableHead scope="row"
│       └── NativeTableCell
└── NativeTableFooter (optional)
    └── NativeTableRow
        ├── NativeTableHead scope="row"
        └── NativeTableCell
```

Each part renders the HTML element it is named after. `NativeTable` is a `table` inside a `div` that scrolls horizontally, `NativeTableHeader`, `NativeTableBody` and `NativeTableFooter` are `thead`, `tbody` and `tfoot`, `NativeTableRow` is a `tr`, `NativeTableHead` a `th` and `NativeTableCell` a `td`. As in HTML, the heading row is a `NativeTableRow` inside `NativeTableHeader`, and rows need no IDs. `NativeTableHead` heads its column by default; in a body row, `scope="row"` makes it the heading of its row.

## Caption and footer

Place `NativeTableCaption` first inside `NativeTable`, where HTML expects the caption. It renders below the table and names it for assistive technology, so the table needs no `aria-label`. `NativeTableFooter` holds summary rows, which sit on the subtle surface at medium weight after a line, as in Table. A footer row with `tone="total"` sets its text in the strong weight. Your application calculates the totals.

Revenue by plan with caption and total

[Complete TSX file](/examples/native-table/caption-footer.tsx)

```tsx
"use client";

import {
  NativeTable,
  NativeTableBody,
  NativeTableCaption,
  NativeTableCell,
  NativeTableFooter,
  NativeTableHead,
  NativeTableHeader,
  NativeTableRow,
} from "@/siteplane/components/native-table";

const plans = [
  { plan: "Starter", customers: "1,204", price: "$12", revenue: "$14,448" },
  { plan: "Team", customers: "386", price: "$49", revenue: "$18,914" },
  { plan: "Business", customers: "92", price: "$199", revenue: "$18,308" },
];

export default function Example() {
  return (
    <NativeTable minWidth={480}>
      <NativeTableCaption>
        Monthly recurring revenue by plan for October 2026.
      </NativeTableCaption>
      <NativeTableHeader>
        <NativeTableRow>
          <NativeTableHead>Plan</NativeTableHead>
          <NativeTableHead align="end">Customers</NativeTableHead>
          <NativeTableHead align="end">Monthly price</NativeTableHead>
          <NativeTableHead align="end">Revenue</NativeTableHead>
        </NativeTableRow>
      </NativeTableHeader>
      <NativeTableBody>
        {plans.map((row) => (
          <NativeTableRow key={row.plan}>
            <NativeTableHead scope="row">{row.plan}</NativeTableHead>
            <NativeTableCell align="end">{row.customers}</NativeTableCell>
            <NativeTableCell align="end">{row.price}</NativeTableCell>
            <NativeTableCell align="end">{row.revenue}</NativeTableCell>
          </NativeTableRow>
        ))}
      </NativeTableBody>
      <NativeTableFooter>
        <NativeTableRow tone="total">
          <NativeTableHead scope="row">Total</NativeTableHead>
          <NativeTableCell align="end">1,682</NativeTableCell>
          <NativeTableCell />
          <NativeTableCell align="end">$51,670</NativeTableCell>
        </NativeTableRow>
      </NativeTableFooter>
    </NativeTable>
  );
}
```

## Cells with controls

A cell can hold any control, such as an [Input](/docs/components/input/) or a [Button](/docs/components/button/) that opens a [Popover](/docs/components/popover/). Each control keeps its own tab stop: in this estimate, Tab moves from the Research estimate to its Edit note button and on to the Prototyping estimate. The arrow keys, Enter and Space act on the control in focus alone, since the table handles no keys. Give a control without a visible label an `aria-label` that names its row, such as Research estimate.

Project estimate with inputs and a note button

[Complete TSX file](/examples/native-table/controls.tsx)

```tsx
"use client";

import { Button } from "@/siteplane/components/button";
import {
  CardFrame,
  CardFrameBleed,
  CardFrameHeader,
  CardFrameTitle,
} from "@/siteplane/components/card";
import { Input } from "@/siteplane/components/input";
import {
  NativeTable,
  NativeTableBody,
  NativeTableCell,
  NativeTableFooter,
  NativeTableHead,
  NativeTableHeader,
  NativeTableRow,
} from "@/siteplane/components/native-table";
import {
  Popover,
  PopoverPopup,
  PopoverTitle,
  PopoverTrigger,
} from "@/siteplane/components/popover";

export default function Example() {
  return (
    <CardFrame className="w-full">
      <CardFrameHeader>
        <CardFrameTitle>Project estimate</CardFrameTitle>
      </CardFrameHeader>
      <CardFrameBleed>
        <NativeTable aria-label="Project estimate" minWidth={560}>
          <NativeTableHeader>
            <NativeTableRow>
              <NativeTableHead>Item</NativeTableHead>
              <NativeTableHead align="end">Estimate ($)</NativeTableHead>
              <NativeTableHead>Notes</NativeTableHead>
            </NativeTableRow>
          </NativeTableHeader>
          <NativeTableBody>
            <NativeTableRow tone="section" hoverable={false}>
              <NativeTableHead scope="rowgroup" colSpan={3}>
                Design
              </NativeTableHead>
            </NativeTableRow>
            <NativeTableRow>
              <NativeTableHead scope="row">Research</NativeTableHead>
              <NativeTableCell align="end">
                <Input
                  aria-label="Research estimate"
                  defaultValue="1200"
                  inputMode="decimal"
                  className="ml-auto w-28"
                />
              </NativeTableCell>
              <NativeTableCell>
                <Popover>
                  <PopoverTrigger
                    render={<Button size="sm" variant="outline" />}
                  >
                    Edit note
                  </PopoverTrigger>
                  <PopoverPopup className="w-72">
                    <div className="grid gap-3">
                      <PopoverTitle>Research note</PopoverTitle>
                      <Input
                        aria-label="Research note"
                        defaultValue="Interviews and analysis"
                      />
                    </div>
                  </PopoverPopup>
                </Popover>
              </NativeTableCell>
            </NativeTableRow>
            <NativeTableRow>
              <NativeTableHead scope="row">Prototyping</NativeTableHead>
              <NativeTableCell align="end">
                <Input
                  aria-label="Prototyping estimate"
                  defaultValue="2400"
                  inputMode="decimal"
                  className="ml-auto w-28"
                />
              </NativeTableCell>
              <NativeTableCell>Interactive prototype</NativeTableCell>
            </NativeTableRow>
            <NativeTableRow tone="muted">
              <NativeTableCell colSpan={3}>
                Amounts exclude tax.
              </NativeTableCell>
            </NativeTableRow>
          </NativeTableBody>
          <NativeTableFooter>
            <NativeTableRow tone="total">
              <NativeTableHead scope="row">Approved budget</NativeTableHead>
              <NativeTableCell align="end">$4,000</NativeTableCell>
              <NativeTableCell />
            </NativeTableRow>
          </NativeTableFooter>
        </NativeTable>
      </CardFrameBleed>
    </CardFrame>
  );
}
```

The table changes nothing about its controls, so the inputs in its cells take part in a form around the table like any other input. Inside `CardFrameBleed`, the first and last columns take the frame’s inset, so the table lines up with the frame header, as [Table](/docs/components/table/#card-composition) does.

## Row tones

`tone` on `NativeTableRow` gives a row its role in a report. A `section` row heads a group of rows on a light tint in the strong weight. Give it one `NativeTableHead` with `scope="rowgroup"` and a `colSpan` across the columns, as Design in the estimate above, and turn off its hover with `hoverable={false}`. A `total` row sums up on a stronger tint, also in the strong weight; inside `NativeTableFooter` it keeps the footer’s surface instead of adding a second tint. A `muted` row sets a note, such as Amounts exclude tax, in the quieter text.

## Alignment

`align` on `NativeTableHead` and `NativeTableCell` sets the text to the `start` of the cell, the default, to its `center` or to its `end`. Start and end follow the writing direction, so they swap sides in a right-to-left layout. `end` also sets tabular numbers, so the digits of amounts line up; give the heading of a number column the alignment of its cells. The tables under Sizes center their plan columns.

## Sizes

`size` sets the height of the heading row and the space above and below the content of each cell. At `sm` the heading row is 32 px tall and a cell keeps 4 px above and below its content; `md` sets 36 and 6 px, `lg` 40 and 8 px, and `xl` 48 and 12 px. Without `size`, the table takes the provider’s [control size](/docs/customization/providers/#control-size), `lg` by default.

Table sizes

[Complete TSX file](/examples/native-table/sizes.tsx)

```tsx
"use client";

import {
  NativeTable,
  NativeTableBody,
  NativeTableCell,
  NativeTableHead,
  NativeTableHeader,
  NativeTableRow,
} from "@/siteplane/components/native-table";

const sizes = [
  { name: "small", size: "sm" },
  { name: "medium", size: "md" },
  { name: "large", size: "lg" },
  { name: "extra large", size: "xl" },
] as const;

const limits = [
  { limit: "Projects", starter: "3", team: "Unlimited" },
  { limit: "Members", starter: "5", team: "50" },
];

export default function Example() {
  return (
    <div className="grid w-full gap-6">
      {sizes.map(({ name, size }) => (
        <NativeTable key={size} aria-label={`Plan limits, ${name}`} size={size}>
          <NativeTableHeader>
            <NativeTableRow>
              <NativeTableHead>Limit</NativeTableHead>
              <NativeTableHead align="center">Starter</NativeTableHead>
              <NativeTableHead align="center">Team</NativeTableHead>
            </NativeTableRow>
          </NativeTableHeader>
          <NativeTableBody>
            {limits.map((row) => (
              <NativeTableRow key={row.limit}>
                <NativeTableHead scope="row">{row.limit}</NativeTableHead>
                <NativeTableCell align="center">{row.starter}</NativeTableCell>
                <NativeTableCell align="center">{row.team}</NativeTableCell>
              </NativeTableRow>
            ))}
          </NativeTableBody>
        </NativeTable>
      ))}
    </div>
  );
}
```

## Markdown tables

[Typeset](/docs/components/typeset/) renders the tables of rendered markdown with Native Table. Its `typesetComponents` map passes `table`, `thead`, `tbody`, `tfoot`, `tr`, `th` and `td` to the matching Native Table parts with the hover turned off, since nothing in written text acts, and gives each cell the alignment of its column. Installing Typeset installs Native Table.

## API reference

### NativeTable

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| size | sm \| md \| lg \| xl | provider / lg | Height of the heading row and space above and below the cell content: 32 and 4 px at sm, 36 and 6 px at md, 40 and 8 px at lg, 48 and 12 px at xl. |
| hoverable | boolean | true | Shows the hover background of the heading row and the body rows. false turns it off for the whole table. |
| minWidth | CSS min-width | unset | Minimum width of the table. In a narrower container, the table keeps this width and scrolls horizontally. |
| className | string | unset | Styles the table element. |
| containerClassName | string | unset | Styles the scrolling container around the table. |
| aria-label / aria-labelledby | string | unset | Accessible name of the table. A NativeTableCaption names the table without them. |
### NativeTableRow

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| tone | section \| total \| muted | unset | section heads a group of rows on a light tint and total sums up on a stronger tint, both in the strong weight; muted sets a note in the quieter text. |
| hoverable | boolean | true | Shows the hover background of a body row. The table’s hoverable turns it off for every row. |
| className | string | unset | Local row styling. |
### NativeTableHead and NativeTableCell

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| align | start \| center \| end | start | Text alignment in the writing direction. end also sets tabular numbers. |
| NativeTableHead.scope | col \| row \| rowgroup \| colgroup | col | The cells the heading names. col names its column, row the other cells of its row and rowgroup the rows of its group. A row or group heading takes the text style of its row. |
| colSpan / rowSpan | number | 1 | Number of columns or rows the cell spans. |
| className | string | unset | Local cell styling. |
### Caption, header, body and footer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| NativeTableCaption | caption props | — | The caption, below the table, which names it. Place it first inside NativeTable. |
| NativeTableHeader | thead props | — | The heading row: a NativeTableRow of NativeTableHead cells. |
| NativeTableBody | tbody props | — | The body rows. |
| NativeTableFooter | tfoot props | — | Summary rows on the subtle surface at medium weight, after a line. |
Every part takes the props of its HTML element, a ref to that element and a
string `className`. `NativeTable` passes its props to the `table`, not to the
scrolling `div`. `NativeTableProps`, `NativeTableRowProps`,
`NativeTableHeadProps` and `NativeTableCellProps` are exported. The
`--table-edge-padding` custom property sets the inline padding of the first and
last columns, and `CardFrameBleed` sets it to the frame’s inset.

[Read the installed source](/source/native-table).

## Usage notes

Give every table an accessible name, with `aria-label` or a `NativeTableCaption`. Make the cell that names each row a row heading, a `NativeTableHead` with `scope="row"`, so a screen reader announces it with the other cells of its row.

The table fills the width of its container. Headings stay on one line, and the text in a cell wraps. When the columns need more room than a narrow screen leaves them, set `minWidth`: the table then keeps that width and scrolls horizontally inside its container, and the page does not.

Native Table holds no state. It renders every row it is given, and sorting, filtering, paging and totals belong to your application. A control in a cell keeps its own value and events.
