Siteplane UI

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.

Markdown
InvoiceCustomerStatusIssuedAmount
INV-2041Brightline LabsPaidSep 2, 2026$4,800.00
INV-2042Harbor & PineOverdueSep 15, 2026$1,250.00
INV-2043Northfield HealthOpenOct 1, 2026$12,400.00
INV-2044Quanta LogisticsPaidOct 14, 2026$860.00
INV-2045Sora StudioDraftOct 27, 2026$3,175.00
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

Native Table uses the stylesheet of Table, so installing it also installs the Table item and react-aria-components.

Usage

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

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.

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.

Monthly recurring revenue by plan for October 2026.
PlanCustomersMonthly priceRevenue
Starter1,204$12$14,448
Team386$49$18,914
Business92$199$18,308
Total1,682$51,670
"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 or a Button that opens a 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
ItemEstimate ($)Notes
Design
Research
PrototypingInteractive prototype
Amounts exclude tax.
Approved budget$4,000
"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 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, lg by default.

LimitStarterTeam
Projects3Unlimited
Members550
LimitStarterTeam
Projects3Unlimited
Members550
LimitStarterTeam
Projects3Unlimited
Members550
LimitStarterTeam
Projects3Unlimited
Members550
"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 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

Component properties
PropTypeDefaultDescription
sizesm | md | lg | xlprovider / lgHeight 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.
hoverablebooleantrueShows the hover background of the heading row and the body rows. false turns it off for the whole table.
minWidthCSS min-widthunsetMinimum width of the table. In a narrower container, the table keeps this width and scrolls horizontally.
classNamestringunsetStyles the table element.
containerClassNamestringunsetStyles the scrolling container around the table.
aria-label / aria-labelledbystringunsetAccessible name of the table. A NativeTableCaption names the table without them.

NativeTableRow

Component properties
PropTypeDefaultDescription
tonesection | total | mutedunsetsection 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.
hoverablebooleantrueShows the hover background of a body row. The table’s hoverable turns it off for every row.
classNamestringunsetLocal row styling.

NativeTableHead and NativeTableCell

Component properties
PropTypeDefaultDescription
alignstart | center | endstartText alignment in the writing direction. end also sets tabular numbers.
NativeTableHead.scopecol | row | rowgroup | colgroupcolThe 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 / rowSpannumber1Number of columns or rows the cell spans.
classNamestringunsetLocal cell styling.
Component properties
PropTypeDefaultDescription
NativeTableCaptioncaption props—The caption, below the table, which names it. Place it first inside NativeTable.
NativeTableHeaderthead props—The heading row: a NativeTableRow of NativeTableHead cells.
NativeTableBodytbody props—The body rows.
NativeTableFootertfoot 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.

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.