# Tables and Lists

Presents collections of records, such as payments, payouts, and API keys, so you can scan, compare, and act on them.

## Overview

Tempo products show many collections: wallet activity, scheduled payouts, API keys, webhooks, and blocks. Each one needs a structure that fits how you read it, consistent alignment, and clear loading, empty, and narrow-screen states.

This page combines:

* `Table` for records compared across the same columns.
* `Rows` for one record's details, settings, and lists of items you open one at a time.
* `Checkbox` and `Button` for selecting rows and acting on them in bulk.
* `Pagination` for moving between pages of a long list.
* `Skeleton` and `Empty` for loading and empty states.

> [!NOTE]
> The live examples are reference layouts composed from library components and `style`. There is no data grid component, so sorting, selection, and paging state live in your app.

## Choose a Structure

| Structure          | Use for                                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `Table`            | Records you compare across the same columns, such as payments, payouts, API keys, or blocks.                     |
| `Rows`             | One record's key-value details, settings, and navigation lists.                                                  |
| Stacked list items | Items with one main line and a few attributes, such as an activity feed. Also the narrow-screen form of a table. |

A record shown on its own is not tabular data. Use `Rows` with `Rows.Label` for its fields, and keep `Table` for several records that share columns.

**Do:** Use `Rows` with `Rows.Label` for the fields of one record.

**Don't:** Build a two-column Field and Value table for a single record.

## Recommended Structure

Place a table in a `Surface`, with a `Surface.Header` above it for the title and actions. A direct child `table` bleeds into the surface's padding, so row fills and dividers reach its edges.

1. A header with the collection's title, filters, and actions such as Export CSV.
2. The table, with a `Table.Caption` or `aria-labelledby` naming it.
3. A footer with the range shown, such as 1–20 of 248, and `Pagination` or a Load more button.

## Align Columns by Content

* Start-align text, such as names, descriptions, and dates.
* End-align numbers and amounts. Set `justify="end"` on the `Table.HeaderCell` and `numeric` on each `Table.Cell`, which sets tabular figures so digits line up.
* Set `mono` on cells that hold hashes or other identifiers, and shorten them in the middle with `formatHex` or `Hex`.
* Put the amount in the last column where you can. The last column aligns to the end automatically.

**Do:** End-align amounts with `numeric` cells so decimals line up.

**Don't:** Start-align amounts, where digits of different lengths don't line up.

## Select Rows and Act in Bulk

Add a leading column of small `Checkbox` controls when you can act on several records at once, such as pausing payouts or revoking keys. Select some rows below to see the bulk actions.

```tsx
import { Amount, Button, Checkbox, Inset, style, Surface, Table, Tag } from '@tempoxyz/ds'
import { ArrowDown, ArrowUp, Sort } from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
const payouts = [
  {
    amount: '1200.00',
    date: '2026-10-20',
    id: 'p1',
    recipient: 'Ada Lovelace',
    status: 'Scheduled',
  },
  { amount: '48.50', date: '2026-10-18', id: 'p2', recipient: 'Grace Hopper', status: 'Scheduled' },
  { amount: '310.00', date: '2026-10-21', id: 'p3', recipient: 'Alan Turing', status: 'Paused' },
  {
    amount: '9.99',
    date: '2026-10-19',
    id: 'p4',
    recipient: 'Katherine Johnson',
    status: 'Scheduled',
  },
]

type Column = 'amount' | 'date'

export function Demo() {
  const [selected, setSelected] = useState<readonly string[]>([])
  const [sort, setSort] = useState<{ column: Column; direction: 'ascending' | 'descending' }>({
    column: 'date',
    direction: 'descending',
  })

  const rows = [...payouts].sort((a, b) => {
    const order =
      sort.column === 'amount' ? Number(a.amount) - Number(b.amount) : a.date.localeCompare(b.date)
    return sort.direction === 'ascending' ? order : -order
  })
  const all = selected.length === payouts.length
  const some = selected.length > 0 && !all

  function toggle(column: Column) {
    setSort((current) => ({
      column,
      direction:
        current.column === column && current.direction === 'descending'
          ? 'ascending'
          : 'descending',
    }))
  }

  function header(column: Column, label: string) {
    const active = sort.column === column
    const Icon = !active ? Sort : sort.direction === 'ascending' ? ArrowUp : ArrowDown

    return (
      <Table.HeaderCell
        aria-sort={active ? sort.direction : undefined}
        justify={column === 'amount' ? 'end' : 'start'}
      >
        <button onClick={() => toggle(column)} type="button" {...styles.sort()}>
          {label}
          <Icon aria-hidden="true" />
        </button>
      </Table.HeaderCell>
    )
  }

  return (
    <Inset aria-label="Scheduled payouts" role="region" space="4" tabIndex={0} {...styles.region()}>
      <Surface {...styles.surface()}>
        <Surface.Header>
          <Surface.Title>Scheduled payouts</Surface.Title>
          <div {...styles.actions()}>
            <span role="status" {...styles.count()}>
              {selected.length > 0 ? `${selected.length} selected` : ''}
            </span>
            {selected.length > 0 ? (
              <>
                <Button onClick={() => setSelected([])} size="small" variant="text">
                  Clear
                </Button>
                <Button size="small" variant="secondary">
                  Pause
                </Button>
                <Button size="small" variant="negative">
                  Cancel payouts
                </Button>
              </>
            ) : (
              <Button size="small" variant="secondary">
                Export CSV
              </Button>
            )}
          </div>
        </Surface.Header>
        <Table.Root>
          <Table.Head>
            <Table.Row>
              <Table.HeaderCell {...styles.check()}>
                <Checkbox
                  aria-label="Select all payouts"
                  checked={all}
                  indeterminate={some}
                  onCheckedChange={() => setSelected(all ? [] : payouts.map((payout) => payout.id))}
                  size="small"
                />
              </Table.HeaderCell>
              <Table.HeaderCell>Recipient</Table.HeaderCell>
              {header('date', 'Date')}
              <Table.HeaderCell>Status</Table.HeaderCell>
              {header('amount', 'Amount')}
            </Table.Row>
          </Table.Head>
          <Table.Body>
            {rows.map((payout) => {
              const checked = selected.includes(payout.id)

              return (
                <Table.Row data-selected={checked || undefined} key={payout.id} {...styles.row()}>
                  <Table.Cell {...styles.check()}>
                    <Checkbox
                      aria-label={`Select payout to ${payout.recipient}`}
                      checked={checked}
                      onCheckedChange={() =>
                        setSelected(
                          checked
                            ? selected.filter((id) => id !== payout.id)
                            : [...selected, payout.id],
                        )
                      }
                      size="small"
                    />
                  </Table.Cell>
                  <Table.Cell>{payout.recipient}</Table.Cell>
                  <Table.Cell>
                    <time dateTime={payout.date}>
                      {new Date(`${payout.date}T12:00:00`).toLocaleDateString(undefined, {
                        day: 'numeric',
                        month: 'short',
                      })}
                    </time>
                  </Table.Cell>
                  <Table.Cell>
                    <Tag intent={payout.status === 'Paused' ? 'warning' : 'neutral'} size="small">
                      {payout.status}
                    </Tag>
                  </Table.Cell>
                  <Table.Cell justify="end" numeric>
                    <Amount align="right" currency="USD" value={payout.amount} />
                  </Table.Cell>
                </Table.Row>
              )
            })}
          </Table.Body>
        </Table.Root>
      </Surface>
    </Inset>
  )
}

namespace styles {
  export const actions = style({ alignItems: 'center', columnGap: '2', display: 'flex' })

  export const check = style({ width: '1px !custom' })

  export const count = style({ color: 'gray.900', typography: 'label.13' })

  export const region = style({ backgroundColor: 'background.primary', overflowX: 'auto' })

  export const row = style({
    selectors: { '&[data-selected]': { backgroundColor: 'grayAlpha.100' } },
  })

  export const sort = style({
    alignItems: 'center',
    appearance: 'none !custom',
    backgroundColor: 'transparent !custom',
    border: 'none !custom',
    borderRadius: 'xs',
    color: 'inherit !custom',
    columnGap: '1',
    cursor: 'pointer',
    display: 'inline-flex',
    font: 'inherit !custom',
    outlineColor: 'transparent !custom',
    outlineOffset: '2px !custom',
    outlineStyle: 'solid !custom',
    outlineWidth: '2px !custom',
    padding: '0',
    transitionDuration: '160ms',
    transitionProperty: 'color, outline-color',
    transitionTimingFunction: 'snappy',
    selectors: {
      '&:focus-visible': { outlineColor: 'blue.700' },
      '&:hover': { color: 'foreground' },
    },
  })

  export const surface = style({ minWidth: '36rem !custom' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/TablesSelection.tsx)

* Name each checkbox by its row, such as Select payout to Ada Lovelace.
* The header checkbox selects every row on the page. Set `indeterminate` while only some rows are selected.
* When rows are selected, show the count, such as 2 selected, with the bulk actions and a Clear action beside it.
* Shade selected rows with a data attribute and `grayAlpha.100`. The checked box still carries the state.
* Give destructive bulk actions the `negative` variant, and confirm them with an `AlertDialog` that names the count, such as Cancel 2 payouts.
* When more pages exist, say that the header checkbox selects only this page, and offer to select all matching records.

## Sort Columns

`Table` has no built-in sorting. Put a `button` inside each sortable `Table.HeaderCell`, and set `aria-sort` to `ascending` or `descending` on the one column that is sorted.

* Show the direction with `ArrowUp` or `ArrowDown`, and mark other sortable columns with `Sort`.
* Sort activity newest first by default. A first click on a numeric column sorts largest first.
* Sort the whole data set, not only the rows on the current page.

## Paginate or Load More

| Pattern         | Use for                                                                                                  |
| --------------- | -------------------------------------------------------------------------------------------------------- |
| `Pagination`    | Lists you return to by position or share, such as invoices, audit logs, or blocks. Give each page a URL. |
| Load more       | Feeds read from the top, such as wallet activity. Append the next batch below the current rows.          |
| Infinite scroll | Avoid on the web. It hides the footer and gives you no position to return to.                            |

Show the range beside either control, such as Showing 20 of 248. After Load more, keep focus on the button and announce how many rows arrived, such as 20 more payments loaded.

Below 640px, show Previous and Next with a Page 1 of 3 label between them, as the stacked example below does, and use `medium` pagination for touch targets.

## Choose a Density

| Size     | Body row | Use for                                                                  |
| -------- | -------- | ------------------------------------------------------------------------ |
| `small`  | 32px     | Dense consoles and dashboards with many rows. Pair with `small` buttons. |
| `medium` | 40px     | Most tables. It is the default.                                          |
| `large`  | 56px     | Touch-first screens and rows with two lines or an avatar.                |

Use one density per table. `Rows` sizes follow the same scale at 36, 40, and 56px.

## Keep Headers in View

Set `sticky` on `Table.Head` when a long table scrolls, either inside a container with a fixed height or with the page. Header cells take the surface background so rows pass beneath them. An ancestor with `overflow: hidden` stops the header from sticking.

Set `fade` on a table that previews a longer list, with a View all link below it. Don't fade a table whose last rows you need to read.

## States

Switch between the states below. The headers stay in place in each one, so the table doesn't jump.

```tsx
import { Amount, Button, ChoiceGroup, Empty, Hex, Skeleton, Stack, style, Table } from '@tempoxyz/ds'
import { History } from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
const activity = [
  ['0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060', 'Sent', '-1200.00'],
  ['0x91bd07e1c3e05a2277a1c0d94f2a91c3b6a900fa2b585dd299e03d12fa4293bc', 'Received', '4005.00'],
  ['0xc3e05a2291bd07e14f2a91c3b6a900fa2b585dd299e03d12fa4293bc77a1c0d9', 'Sent', '-48.50'],
  ['0x77a1c0d94f2a91c3b6a900fa2b585dd299e03d12fa4293bcc3e05a2291bd07e1', 'Received', '250.00'],
  ['0x2b585dd299e03d12fa4293bc77a1c0d94f2a91c3b6a900fac3e05a2291bd07e1', 'Sent', '-9.99'],
  ['0xd299e03d12fa4293bc77a1c0d94f2a91c3b6a900fa2b585dc3e05a2291bd07e1', 'Received', '1250.50'],
  ['0x4f2a91c3b6a900fa2b585dd299e03d12fa4293bc77a1c0d9c3e05a2291bd07e1', 'Sent', '-310.00'],
  ['0xb6a900fa2b585dd299e03d12fa4293bc77a1c0d94f2a91c3c3e05a2291bd07e1', 'Received', '75.25'],
] as const

type State = 'empty' | 'loaded' | 'loading'

export function Demo() {
  const [state, setState] = useState<State>('loaded')

  return (
    <Stack gap="4">
      <ChoiceGroup.Root
        aria-label="Table state"
        onValueChange={(value) => setState(value as State)}
        size="small"
        value={state}
      >
        <ChoiceGroup.Item value="loaded">Loaded</ChoiceGroup.Item>
        <ChoiceGroup.Item value="loading">Loading</ChoiceGroup.Item>
        <ChoiceGroup.Item value="empty">Empty</ChoiceGroup.Item>
      </ChoiceGroup.Root>
      <div aria-labelledby="activity-title" role="region" tabIndex={0} {...styles.scroll()}>
        <Table.Root aria-busy={state === 'loading' || undefined} size="small">
          <Table.Caption id="activity-title">Recent activity</Table.Caption>
          <Table.Head sticky>
            <Table.Row>
              <Table.HeaderCell>Transaction</Table.HeaderCell>
              <Table.HeaderCell>Type</Table.HeaderCell>
              <Table.HeaderCell justify="end">Amount</Table.HeaderCell>
            </Table.Row>
          </Table.Head>
          <Table.Body>
            {state === 'loaded' &&
              activity.map(([hash, type, amount]) => (
                <Table.Row key={hash}>
                  <Table.Cell>
                    <div {...styles.hash()}>
                      <Hex value={hash} />
                    </div>
                  </Table.Cell>
                  <Table.Cell>{type}</Table.Cell>
                  <Table.Cell numeric>
                    <Amount
                      align="right"
                      sign={amount.startsWith('-') ? '−' : '+'}
                      symbol="USDC"
                      value={amount.replace('-', '')}
                    />
                  </Table.Cell>
                </Table.Row>
              ))}
            {state === 'loading' &&
              ['60%', '45%', '70%', '50%'].map((width) => (
                <Table.Row key={width}>
                  <Table.Cell>
                    <Skeleton shape="pill" style={{ height: 12, width }} />
                  </Table.Cell>
                  <Table.Cell>
                    <Skeleton shape="pill" style={{ height: 12, width: '4rem' }} />
                  </Table.Cell>
                  <Table.Cell>
                    <Skeleton
                      shape="pill"
                      style={{ height: 12, marginInlineStart: 'auto', width: '5rem' }}
                    />
                  </Table.Cell>
                </Table.Row>
              ))}
            {state === 'empty' && (
              <Table.Row>
                <Table.Cell colSpan={3}>
                  <Empty.Root>
                    <Empty.Icon>
                      <History />
                    </Empty.Icon>
                    <Empty.Title>No activity yet</Empty.Title>
                    <Empty.Description>Payments you send or receive appear here.</Empty.Description>
                    <Empty.Actions>
                      <Button size="small">Add funds</Button>
                    </Empty.Actions>
                  </Empty.Root>
                </Table.Cell>
              </Table.Row>
            )}
          </Table.Body>
        </Table.Root>
      </div>
      {state === 'loaded' && (
        <div {...styles.footer()}>
          <span {...styles.count()}>Showing 8 of 24</span>
          <Button size="small" variant="secondary">
            Load more
          </Button>
        </div>
      )}
    </Stack>
  )
}

namespace styles {
  export const count = style({ color: 'gray.900', typography: 'label.13' })

  export const footer = style({
    alignItems: 'center',
    display: 'flex',
    flexWrap: 'wrap',
    gap: '3',
    justifyContent: 'space-between',
  })

  export const hash = style({ maxWidth: '12rem !custom' })

  export const scroll = style({
    backgroundColor: 'background.surface',
    borderColor: 'grayAlpha.200',
    borderRadius: '2xl',
    borderStyle: 'solid',
    borderWidth: '1px !custom',
    maxHeight: '18rem !custom',
    overflowY: 'auto',
  })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/TablesStates.tsx)

* **Loading.** Keep the headers and render three to five rows of `Skeleton` bars, with `aria-busy` on the table. In `Rows`, use `Rows.RowGroupLoader` and `Rows.PanelLoader`.
* **Empty.** When a collection has no records yet, show an `Empty` with the action that creates the first one, such as Add funds or Create key.
* **No results.** When a filter returns nothing, keep the headers and filters, and explain in a full-width row with a way to clear the filter.
* **Error.** Show a `Callout` with `intent="negative"` above the table and a Retry action. Keep rows that already loaded.
* **Updating.** Keep the current rows while a sort or filter loads, and replace them when the new rows arrive.

**Do:** Explain an empty result in a row that spans every column, with a way out.

**Don't:** Leave headers above an empty body.

## Below 640px

Tables with more than two or three columns don't fit a phone. Turn each row into a stacked list item, with the main text and amount on the first line and the date and status beneath. Resize the preview below 640px to see the change.

```tsx
import { Amount, Pagination, Rows, Stack, style, Surface, Table, Tag } from '@tempoxyz/ds'

export const fill = true
const payments = [
  { amount: '1200.00', date: 'Oct 20', recipient: 'Ada Lovelace', status: 'Confirmed' },
  { amount: '48.50', date: 'Oct 19', recipient: 'Grace Hopper', status: 'Confirmed' },
  { amount: '310.00', date: 'Oct 18', recipient: 'Alan Turing', status: 'Pending' },
  { amount: '9.99', date: 'Oct 18', recipient: 'Katherine Johnson', status: 'Failed' },
] as const

const intents = { Confirmed: 'positive', Failed: 'negative', Pending: 'warning' } as const

export function Demo() {
  return (
    <Stack gap="4" {...styles.demo()}>
      <h2 id="payments-title" {...styles.title()}>
        Payments
      </h2>
      {/* Both layouts render, and `display: none` keeps only the visible one in the accessibility tree. */}
      <div {...styles.wide()}>
        <Surface>
          <Table.Root aria-labelledby="payments-title">
            <Table.Head>
              <Table.Row>
                <Table.HeaderCell>Recipient</Table.HeaderCell>
                <Table.HeaderCell>Date</Table.HeaderCell>
                <Table.HeaderCell>Status</Table.HeaderCell>
                <Table.HeaderCell justify="end">Amount</Table.HeaderCell>
              </Table.Row>
            </Table.Head>
            <Table.Body>
              {payments.map((payment) => (
                <Table.Row key={payment.recipient}>
                  <Table.Cell>{payment.recipient}</Table.Cell>
                  <Table.Cell>{payment.date}</Table.Cell>
                  <Table.Cell status={intents[payment.status]}>{payment.status}</Table.Cell>
                  <Table.Cell numeric>
                    <Amount align="right" currency="USD" value={payment.amount} />
                  </Table.Cell>
                </Table.Row>
              ))}
            </Table.Body>
          </Table.Root>
        </Surface>
      </div>
      <div {...styles.narrow()}>
        <Surface size="small">
          <Rows.Root aria-labelledby="payments-title" role="list">
            {payments.map((payment) => (
              <Rows.Row key={payment.recipient} role="listitem">
                <Rows.Info>
                  <Rows.Title>{payment.recipient}</Rows.Title>
                  <Rows.Description>{payment.date}</Rows.Description>
                </Rows.Info>
                <span {...styles.end()}>
                  <Amount currency="USD" value={payment.amount} />
                  <Tag intent={intents[payment.status]} size="small">
                    {payment.status}
                  </Tag>
                </span>
              </Rows.Row>
            ))}
          </Rows.Root>
        </Surface>
      </div>
      <div {...styles.footer()}>
        <span {...styles.count()}>1–4 of 12</span>
        <div {...styles.wide()}>
          <Pagination>
            <Pagination.Previous disabled />
            <Pagination.Page current href="#">
              1
            </Pagination.Page>
            <Pagination.Page href="#">2</Pagination.Page>
            <Pagination.Page href="#">3</Pagination.Page>
            <Pagination.Next href="#" />
          </Pagination>
        </div>
        <div {...styles.narrow()}>
          <Pagination>
            <Pagination.Previous disabled />
            <Pagination.Ellipsis>Page 1 of 3</Pagination.Ellipsis>
            <Pagination.Next href="#" />
          </Pagination>
        </div>
      </div>
    </Stack>
  )
}

namespace styles {
  export const count = style({
    color: 'gray.900',
    display: 'none',
    typography: 'label.13',
    '@media sm': { display: 'inline' },
  })

  export const demo = style({ backgroundColor: 'background.primary', padding: '4' })

  export const end = style({
    alignItems: 'flex-end',
    display: 'flex',
    flexDirection: 'column',
    flexShrink: '0 !custom',
    rowGap: '1',
  })

  export const footer = style({
    alignItems: 'center',
    display: 'flex',
    flexWrap: 'wrap',
    gap: '3',
    justifyContent: 'space-between',
  })

  export const narrow = style({ '@media sm': { display: 'none' } })

  export const title = style({ margin: '0', typography: 'heading.20' })

  export const wide = style({ display: 'none', '@media sm': { display: 'block' } })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/TablesResponsive.tsx)

* Render the `Table` from 640px and a `Rows` list below it, switching with `display: none` at `sm`. Only the visible layout stays in the accessibility tree.
* Keep the same records, order, and actions in both layouts. Move columns that don't fit into the record's detail view.
* Keep a table only when you compare values across columns, such as benchmark results. Let it scroll horizontally inside a named region with `tabIndex={0}`, never the whole page.

## Content

* Write column headers as short nouns in sentence case, such as Recipient or Last used.
* Put a metric's unit in its header, such as p99 (ms). Keep the token or currency on each amount, since one table can mix assets.
* Show a missing value as a word, such as None or Never, instead of an empty cell.
* Format dates the same way in every row, and use relative times only for recent activity, such as 5 min ago.

## Accessibility

* `Table` renders native `table`, `thead`, and `th` elements, and `Table.HeaderCell` sets `scope="col"`. Pass `scope="row"` for a row header.
* Name every table with `Table.Caption` or `aria-labelledby`. Don't use a table for layout.
* `interactive` only adds hover and focus styles. Put a link or button in each clickable row, so it works with a keyboard.
* Sort with a native `button` in the header and `aria-sort` on that `th`. Don't put the click handler on the `th` itself.
* Announce the selection count and loaded batches through a `role="status"` region that is always rendered.
* `Table.Cell` with `status` colors text only. The text must state the status, such as Failed.
* Small 32px rows stay above the WCAG 2.2 minimum target size of 24px, and `Checkbox` keeps a hit area of at least 36px.

## Related

* [Table](/docs/components/table) for tabular data
* [Rows](/docs/components/rows) for details, settings, and stacked items
* [Pagination](/docs/components/pagination) for paged lists
* [Checkbox](/docs/components/checkbox) for row selection
* [Responsive Layout](/docs/patterns/responsive-layout) for the shared breakpoints
* [Loading](/docs/patterns/loading) for skeletons and spinners
* [Empty States](/docs/patterns/empty-states) for first use and no results
* [Destructive Actions](/docs/patterns/destructive-actions) for confirming bulk actions
