Table

Rows and columns that stay lined up, with sortable headers.

Rows and columns that stay lined up. React Native has no table layout, so a table here is a stack of flex rows whose cells divide the width the same way — and the component owns everything that is not that: the hairlines between rows, the muted header and footer bands, optional striping, per-column alignment, and a sort arrow that turns over rather than being swapped.

Installation

Table ships with the library — no separate install.

import { Table, Badge, ScrollFade } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add table

Usage

<Table variant="outline">
  <Table.Header>
    <Table.Row>
      <Table.Head flex={2}>Invoice</Table.Head>
      <Table.Head align="end">Amount</Table.Head>
    </Table.Row>
  </Table.Header>
  <Table.Body>
    <Table.Row>
      <Table.Cell flex={2}>INV-001</Table.Cell>
      <Table.Cell align="end">$250.00</Table.Cell>
    </Table.Row>
  </Table.Body>
</Table>

Composition

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

A Table.Head and the Table.Cells beneath it must be given the same flex or width, or the column drifts. That is the one rule the component cannot enforce for you: nothing in React Native connects the third cell of one row to the third cell of the next except both of them dividing the row the same way.

Examples

Basic

Cells divide the row evenly unless told otherwise. flex={2} gives a column twice the share of the leftover width, and align="end" puts a money column’s digits against the same edge so they read as a column.

<Table variant="outline">
  <Table.Header>
    <Table.Row>
      <Table.Head flex={2}>Invoice</Table.Head>
      <Table.Head>Method</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 flex={2}>{invoice.id}</Table.Cell>
        <Table.Cell>{invoice.method}</Table.Cell>
        <Table.Cell align="end">{invoice.amount}</Table.Cell>
      </Table.Row>
    ))}
  </Table.Body>
  <Table.Footer>
    <Table.Row>
      <Table.Cell flex={2} labelClassName="font-medium">Total</Table.Cell>
      <Table.Cell />
      <Table.Cell align="end" labelClassName="font-medium">$1,750.00</Table.Cell>
    </Table.Row>
  </Table.Footer>
</Table>

Striped

Tints every other body row, which helps the eye track across a wide one. Leave it off for a short table, where the stripes are louder than the data.

<Table variant="outline" striped>
  {/* …header and body… */}
</Table>

Sortable columns

A header given onPress is the handle for sorting by its column. Pass sortDirection to the column being sorted by and leave it off the others; the arrow rotates between the two directions rather than being replaced, and sits dimmed on the columns that could be sorted but are not.

const [column, setColumn] = useState<'id' | 'amount'>('amount');
const [direction, setDirection] = useState<'asc' | 'desc'>('desc');

const sortBy = (next: 'id' | 'amount') => {
  if (next === column) {
    setDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
    return;
  }
  setColumn(next);
  setDirection('asc');
};

<Table.Head
  flex={2}
  sortable
  sortDirection={column === 'id' ? direction : undefined}
  onPress={() => sortBy('id')}
>
  Invoice
</Table.Head>

Selectable rows

A row with an onPress announces itself as a button rather than as a row, since being tappable is the more useful fact. selected lights the chosen one.

const [picked, setPicked] = useState('INV-002');

<Table.Body>
  {invoices.map((invoice) => (
    <Table.Row
      key={invoice.id}
      selected={picked === invoice.id}
      onPress={() => setPicked(invoice.id)}
    >
      <Table.Cell flex={2}>{invoice.id}</Table.Cell>
      <Table.Cell>
        <Badge variant="success">{invoice.status}</Badge>
      </Table.Cell>
    </Table.Row>
  ))}
</Table.Body>

Wider than the screen

More columns than a phone is wide belong in a horizontal scroller, not squeezed until the columns are unreadable. Give the table a minWidth and fixed width per column, and wrap it in ScrollFade so the fading edge says there is more to the right.

<ScrollFade size={24}>
  <ScrollView horizontal showsHorizontalScrollIndicator={false}>
    <Table variant="outline" size="sm" striped style={{ minWidth: 560 }}>
      <Table.Header>
        <Table.Row>
          <Table.Head width={96}>Invoice</Table.Head>
          <Table.Head width={110}>Customer</Table.Head>
          <Table.Head width={110} align="end">Amount</Table.Head>
        </Table.Row>
      </Table.Header>
      {/* …body, with the same widths… */}
    </Table>
  </ScrollView>
</ScrollFade>

Nothing to show

A header with no rows under it looks broken rather than empty. Table.Empty replaces the body and keeps the header, which is worth keeping: it says what would be there.

<Table variant="outline">
  <Table.Header>
    <Table.Row>
      <Table.Head flex={2}>Invoice</Table.Head>
      <Table.Head align="end">Amount</Table.Head>
    </Table.Row>
  </Table.Header>
  <Table.Empty>No invoices yet</Table.Empty>
</Table>

Variants

variant

  • default (default)
  • outline
<Table variant="default">…</Table>
<Table variant="outline">…</Table>

size

  • default (default)
  • sm
<Table size="default">…</Table>
<Table size="sm">…</Table>

API Reference

Table

PropTypeDefaultDescription
classNamestring
sizeTableSizedefaultRow density. Table.Row, Table.Head and Table.Cell follow it, so it only needs setting here.
stripedbooleanTint every other body row. Helps the eye track across a wide row; drop it for a short table, where the stripes are louder than the data.

Table.Header

PropTypeDefaultDescription
classNamestring

Table.Body

PropTypeDefaultDescription
classNamestring

Table.Footer

PropTypeDefaultDescription
classNamestring

Table.Row

PropTypeDefaultDescription
classNamestring
selectedbooleanMarks the row as the chosen one — for a table you pick from.
disabledboolean
indexnumberPosition in the section, for a row rendered outside Table.Body — a FlatList item, say. Decides which rows a striped table tints.
lastbooleanWhether this is the section's final row, for a row rendered outside Table.Body. The last row drops its hairline so it does not double up with the table's own bottom edge.

Table.Head

PropTypeDefaultDescription
classNamestring
flexnumberShare of the leftover width, relative to the other cells in the row. Defaults to 1, so columns divide the row evenly. Must match the flex on the cells beneath it.
widthnumberFixed width in pixels, for a column that must not move — an icon, a state dot. Must match the width on the cells beneath it.
alignCellAlign'start'Which edge the column's content sits against. Use end for numbers: a money column reads as a column only when the digits line up.
sortablebooleanShow the sort arrow without committing to a direction — the column can be sorted, but is not the one being sorted by. Implied by sortDirection.
sortDirectionTableSortDirectionThe direction this column is currently sorted in. Turns the arrow over.
onPressAnimatedPressableProps['onPress']Called on a tap. Supplying it makes the header a button.
labelClassNamestringStyles the header's text.

Table.Cell

PropTypeDefaultDescription
classNamestring
flexnumberShare of the leftover width, relative to the other cells in the row. Defaults to 1. Must match the flex on the head above it.
widthnumberFixed width in pixels, for a column that must not move. Must match the width on the head above it.
alignCellAlign'start'Which edge the cell's content sits against. Match the head above it.
labelClassNamestringStyles the cell's text.

Table.Caption

PropTypeDefaultDescription
classNamestring

Table.Empty

PropTypeDefaultDescription
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Columns are a convention, not a mechanism

There is no column model. A column exists because every row divides its width the same way, so flex and width on a Table.Head and on the Table.Cells beneath it have to agree. flex is a share of the leftover width — the default of 1 makes columns even — and width pins a column that must not move, such as an icon or a state dot.

Long tables

Table.Body renders every row it is given, so a table of thousands belongs in a FlatList instead. Table.Row takes index and last directly for that case: a virtualised row has no Table.Body above it to read its position from, and without them a striped table would tint nothing and every row would keep its hairline.

<FlatList
  data={invoices}
  renderItem={({ item, index }) => (
    <Table.Row index={index} last={index === invoices.length - 1}>
      <Table.Cell flex={2}>{item.id}</Table.Cell>
      <Table.Cell align="end">{item.amount}</Table.Cell>
    </Table.Row>
  )}
/>

Wrap the list in a Table so the rows still read the density and striping, and put the header row above the list rather than inside it.

Accessibility

The root, the bands, the rows and the cells carry table, rowgroup, row, columnheader and cell roles, so a screen reader walks the table as a table. A row given onPress is the exception: it announces itself as a button, because being able to act on it is the more useful fact about it.

On this page