Table

Pass columns and rows and the table builds itself, header included. It is wrapped in a horizontal scroll container, so a table with more columns than a phone can show scrolls on its own rather than stretching the page. Exported as both table and dataTable.

Basic table

PageSizeRender time
/4.1 kB12 ms
/docs12.7 kB31 ms
/examples9.4 kB24 ms
table({
  columns: [
    { key: 'page', header: 'Page' },
    { key: 'size', header: 'Size' },
    { key: 'time', header: 'Render time' },
  ],
  rows: [
    { page: '/', size: '4.1 kB', time: '12 ms' },
    { page: '/docs', size: '12.7 kB', time: '31 ms' },
    { page: '/examples', size: '9.4 kB', time: '24 ms' },
  ],
})

Alignment

Numbers read better aligned to the end of their column.

PageBytesGzip
/4,1121,204
/docs12,7043,910
/examples9,3882,744
table({
  columns: [
    { key: 'page', header: 'Page' },
    { key: 'bytes', header: 'Bytes', align: 'end' },
    { key: 'gzip', header: 'Gzip', align: 'end' },
  ],
  rows: [
    { page: '/', bytes: '4,112', gzip: '1,204' },
    { page: '/docs', bytes: '12,704', gzip: '3,910' },
    { page: '/examples', bytes: '9,388', gzip: '2,744' },
  ],
})

Custom cells

A column with a render function gets the whole row and returns whatever should be in the cell — a chip, a link, a formatted number.

PageSizeStatus
/docs/routing18.2 kBok
/docs/data21.7 kBok
/docs/islands24.1 kBfailed
table({
  columns: [
    { header: 'Page', render: (row) => link({ href: row.href }, row.page) },
    { key: 'size', header: 'Size', align: 'end' },
    { header: 'Status', align: 'end', render: (row) =>
      chip({ size: 'sm', dot: true, color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : 'failed') },
  ],
  rows: [
    { page: '/docs/routing', href: '/docs/routing', size: '18.2 kB', ok: true },
    { page: '/docs/data', href: '/docs/data', size: '21.7 kB', ok: true },
    { page: '/docs/islands', href: '/docs/islands', size: '24.1 kB', ok: false },
  ],
})

Styles

striped bands alternate rows, hover highlights the row under the pointer, and dense tightens the padding for a table with many rows.

striped
NameValue
pages169
assets208
total9.5 MB
hover and dense
NameValue
pages169
assets208
total9.5 MB
stack({ gap: 'lg' },
  table({
    striped: true,
    caption: 'striped',
    columns: [{ key: 'name', header: 'Name' }, { key: 'value', header: 'Value', align: 'end' }],
    rows: [{ name: 'pages', value: '169' }, { name: 'assets', value: '208' }, { name: 'total', value: '9.5 MB' }],
  }),
  table({
    hover: true,
    dense: true,
    caption: 'hover and dense',
    columns: [{ key: 'name', header: 'Name' }, { key: 'value', header: 'Value', align: 'end' }],
    rows: [{ name: 'pages', value: '169' }, { name: 'assets', value: '208' }, { name: 'total', value: '9.5 MB' }],
  }),
)

Caption

A caption names the table for anyone who arrives at it without the surrounding text — worth adding whenever the table is not directly under a heading that already says what it is.

Build output, newest first
CommitWhenPages
94a837a4 minutes ago169
dcfaaae2 hours ago161
table({
  caption: 'Build output, newest first',
  columns: [
    { key: 'commit', header: 'Commit' },
    { key: 'when', header: 'When' },
    { key: 'pages', header: 'Pages', align: 'end' },
  ],
  rows: [
    { commit: '94a837a', when: '4 minutes ago', pages: '169' },
    { commit: 'dcfaaae', when: '2 hours ago', pages: '161' },
  ],
})

From data

Rows are an ordinary array, so they are usually whatever data() already loaded — no adapter in between.

PostPublishedReads
Hello world2026-01-141,204
Static first2026-02-02890
No runtime2026-03-192,317
return (() => {
  const posts = [
    { title: 'Hello world', date: '2026-01-14', reads: 1204 },
    { title: 'Static first', date: '2026-02-02', reads: 890 },
    { title: 'No runtime', date: '2026-03-19', reads: 2317 },
  ]

  return table({
    hover: true,
    columns: [
      { key: 'title', header: 'Post' },
      { key: 'date', header: 'Published' },
      { header: 'Reads', align: 'end', render: (post) => post.reads.toLocaleString('en') },
    ],
    rows: posts,
  })
})()

Writing the markup yourself

Omit columns and the table renders its children instead, so a table with a footer row or grouped headers can be hand-built and still get the styling and the scroll container.

Props

PropTypeDefaultDescription
columnsTableColumn[]{ key, header, align, render } per column. Omit to hand-write the rows.
rowsobject[][]One object per row.
captionChildA caption above the table.
stripedbooleanfalseBand alternate rows.
hoverbooleanfalseHighlight the row under the pointer.
densebooleanfalseTighter cell padding.