Table

Rows of records with the same columns, with numbers aligned and a header that stays pinned.

import { Table } from '@/components/panelui/table';

const INVOICES = [
  { id: 'INV-2041', client: 'Larkspur Joinery', due: '4 Nov', amount: '£1,280.00' },
  { id: 'INV-2042', client: 'Harbour Bakery', due: '9 Nov', amount: '£312.50' },
  { id: 'INV-2043', client: 'Northgate Clinic', due: '12 Nov', amount: '£4,906.00' },
  { id: 'INV-2044', client: 'Fen Lane Cycles', due: '20 Nov', amount: '£86.40' },
];

export default function TableOverview() {
  return (
    <Table className="min-w-md">
      <Table.Header>
        <Table.Row>
          <Table.Head>Invoice</Table.Head>
          <Table.Head>Client</Table.Head>
          <Table.Head>Due</Table.Head>
          <Table.Head align="end">Amount</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {INVOICES.map((invoice) => (
          <Table.Row key={invoice.id}>
            <Table.Cell className="font-medium">{invoice.id}</Table.Cell>
            <Table.Cell>{invoice.client}</Table.Cell>
            <Table.Cell className="text-muted-foreground">{invoice.due}</Table.Cell>
            <Table.Cell align="end">{invoice.amount}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Use a table for records that share the same columns and are compared down a column: invoices, stock, readings.

For a list of things with one or two details each, Frame rows read better on a narrow screen.

A table wider than its container scrolls sideways inside its own box.

Installation

npx shadcn@latest add https://panelui.dev/r/web/table.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { Table } from "@/components/panelui/table"
<Table>
  <Table.Header>
    <Table.Row>
      <Table.Head>Invoice</Table.Head>
      <Table.Head align="end">Amount</Table.Head>
    </Table.Row>
  </Table.Header>
  <Table.Body>
    <Table.Row>
      <Table.Cell>INV-2041</Table.Cell>
      <Table.Cell align="end">£1,280.00</Table.Cell>
    </Table.Row>
  </Table.Body>
</Table>

Composition

<Table>
  <Table.Caption />
  <Table.Header>
    <Table.Row>
      <Table.Head />
    </Table.Row>
  </Table.Header>
  <Table.Body>
    <Table.Row>
      <Table.Cell />
    </Table.Row>
  </Table.Body>
  <Table.Footer />
</Table>

Examples

Compact and striped

density="compact" tightens the rows for a dense screen. striped shades every other row, which helps keep your place across a wide row.

import { Badge } from '@/components/panelui/badge';
import { Table } from '@/components/panelui/table';

const STOCK = [
  { sku: 'OAK-12', item: 'Oak board, 12mm', bin: 'A3', qty: 148, state: 'ok' },
  { sku: 'OAK-18', item: 'Oak board, 18mm', bin: 'A4', qty: 22, state: 'low' },
  { sku: 'ASH-18', item: 'Ash board, 18mm', bin: 'B1', qty: 0, state: 'out' },
  { sku: 'BIR-09', item: 'Birch ply, 9mm', bin: 'C2', qty: 310, state: 'ok' },
  { sku: 'BIR-15', item: 'Birch ply, 15mm', bin: 'C3', qty: 64, state: 'ok' },
  { sku: 'WAL-20', item: 'Walnut slab, 20mm', bin: 'D1', qty: 7, state: 'low' },
] as const;

const STATE = {
  ok: { label: 'In stock', variant: 'success' },
  low: { label: 'Low', variant: 'warning' },
  out: { label: 'Out', variant: 'destructive' },
} as const;

export default function TableDense() {
  return (
    <Table density="compact" striped className="min-w-md">
      <Table.Header>
        <Table.Row>
          <Table.Head>SKU</Table.Head>
          <Table.Head>Item</Table.Head>
          <Table.Head>Bin</Table.Head>
          <Table.Head align="end">Qty</Table.Head>
          <Table.Head align="end">Status</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {STOCK.map((row) => (
          <Table.Row key={row.sku}>
            <Table.Cell className="font-mono text-xs">{row.sku}</Table.Cell>
            <Table.Cell>{row.item}</Table.Cell>
            <Table.Cell className="text-muted-foreground">{row.bin}</Table.Cell>
            <Table.Cell align="end">{row.qty}</Table.Cell>
            <Table.Cell align="end">
              <Badge size="sm" variant={STATE[row.state].variant}>
                {STATE[row.state].label}
              </Badge>
            </Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Pinned header

maxHeight makes the body scroll, with the header pinned above it. framed keeps the first and last columns off the edge of a bordered box.

import { Table } from '@/components/panelui/table';

const READINGS = Array.from({ length: 24 }, (_, hour) => {
  const temp = 11 + Math.round(6 * Math.sin(((hour - 9) / 24) * Math.PI * 2) * 10) / 10;
  const wind = 8 + ((hour * 7) % 11);
  const rain = hour >= 15 && hour <= 18 ? ((hour * 3) % 5) / 2 + 0.5 : 0;
  return { time: `${String(hour).padStart(2, '0')}:00`, temp, wind, rain };
});

export default function TableScroll() {
  return (
    <Table maxHeight="16rem" containerClassName="w-full max-w-md rounded-lg border" framed>
      <Table.Caption className="pb-3">Hourly readings, weather station 4</Table.Caption>
      <Table.Header>
        <Table.Row>
          <Table.Head>Time</Table.Head>
          <Table.Head align="end">Temp °C</Table.Head>
          <Table.Head align="end">Wind km/h</Table.Head>
          <Table.Head align="end">Rain mm</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {READINGS.map((row) => (
          <Table.Row key={row.time}>
            <Table.Cell className="tabular-nums">{row.time}</Table.Cell>
            <Table.Cell align="end">{row.temp.toFixed(1)}</Table.Cell>
            <Table.Cell align="end">{row.wind}</Table.Cell>
            <Table.Cell align="end">{row.rain.toFixed(1)}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

In a Frame

In a panel with no padding, framed lines the columns up with the panel's header.

import { Frame } from '@/components/panelui/frame';
import { Table } from '@/components/panelui/table';

const TEAM = [
  { name: 'Ife Adeyemi', role: 'Owner', seats: 'All projects', last: 'Now' },
  { name: 'Tomás Mendes', role: 'Editor', seats: '3 projects', last: '2 h ago' },
  { name: 'Hana Kato', role: 'Viewer', seats: '1 project', last: 'Yesterday' },
];

export default function TableFrame() {
  return (
    <Frame className="w-full max-w-lg">
      <Frame.Header>
        <Frame.Title>Members</Frame.Title>
        <Frame.Action>3 of 5 seats</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-0">
        <Table framed>
          <Table.Header>
            <Table.Row>
              <Table.Head>Name</Table.Head>
              <Table.Head>Role</Table.Head>
              <Table.Head>Access</Table.Head>
              <Table.Head align="end">Last seen</Table.Head>
            </Table.Row>
          </Table.Header>
          <Table.Body>
            {TEAM.map((person) => (
              <Table.Row key={person.name}>
                <Table.Cell className="font-medium">{person.name}</Table.Cell>
                <Table.Cell>{person.role}</Table.Cell>
                <Table.Cell className="text-muted-foreground">{person.seats}</Table.Cell>
                <Table.Cell align="end" className="text-muted-foreground">
                  {person.last}
                </Table.Cell>
              </Table.Row>
            ))}
          </Table.Body>
        </Table>
      </Frame.Panel>
    </Frame>
  );
}

API Reference

Table

PropTypeDefaultDescription
densityTableDensity'comfortable'Row height. compact for a dense screen. Default comfortable.
stripedbooleanfalseShades every other body row.
framedbooleanfalseDraws the cells clear of the edges, for a table set inside a Card or Frame panel, whose edge the first and last columns would otherwise touch.
maxHeightCSSProperties['maxHeight']—The tallest the table gets before its body scrolls, with the header pinned above. Any CSS length.
containerClassNamestring—Classes for the scrolling box around the table, rather than the table.

Table.Head

PropTypeDefaultDescription
alignTableAlignstartWhere the column's content sits. end for numbers. Default start.

Table.Cell

PropTypeDefaultDescription
alignTableAlignstartWhere the content sits. Match the column's head. end also gives the digits equal widths. Default start.

Every part also takes the props of the element it renders, and a className.

Notes

Numbers

align="end" on a column's head and cells right-aligns it and gives the digits equal widths, so amounts line up on their last digit. Set it on both.

Accessibility

Every part is the native table element, and Table.Head has scope="col", so a screen reader announces each cell's column. Table.Caption names the table.

Server Components

Table is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Table.Header off the import, so each part is also exported by name: TableHeader is Table.Header, and so on for the others.

import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from "@/components/panelui/table"

On this page