Rows of data with sorting, links to a detail, a sticky header, columns of figures, totals, selection with a bulk-action bar, and loading, empty and error states.
Basic
Pass the rows as items, the columns as definitions and a key for each row. Each column says how its cell reads: a string or any node. With getRowHref, a press anywhere on a row opens its detail through your router, as a real link in the row header's cell, so a middle click or a modifier click opens a new tab and the link's menu is the browser's. A sortable header is a button: sortDescriptor and onSortChange are controlled, ascending first and then descending, with no unsorted state. currentKey marks the row whose detail is open.
Example
Example
| Date | Payee | Category | Amount |
|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 |
| 14 Oct | City Transit | Transport | −$2.75 |
| 13 Oct | Northside Rent | Rent | −$1,450.00 |
| 13 Oct | Acme Payroll | Salary | $3,125.00 |
| 12 Oct | Green Grocer | Groceries | −$28.90 |
| 12 Oct | Bright Power | Utilities | −$86.40 |
'use client';
import { DataTable, RouterProvider, type DataTableColumn } from '@snapshot-ui/react';
import { useRouter } from 'next/navigation';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
interface Sort {
column: string;
direction: 'ascending' | 'descending';
}
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), allowsSorting: true, width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
listSlot: 'end',
allowsSorting: true,
width: '26%',
},
];
/** Sorts a copy of the rows as the app's server would, by the URL's sort. */
function sorted(rows: readonly Transaction[], sort: Sort): Transaction[] {
return [...rows].sort((a, b) => {
const order = sort.column === 'amount' ? a.minor - b.minor : a.date.localeCompare(b.date);
return sort.direction === 'ascending' ? order : -order;
});
}
export default function DataTableBasic(): ReactElement {
const router = useRouter();
// An app keeps the sort and the open row in its URL; the example keeps them in state.
const [sort, setSort] = useState<Sort>({ column: 'date', direction: 'descending' });
const [current, setCurrent] = useState<string | null>('t2');
return (
<div style={{ width: '100%' }}>
{/* An app wraps its whole tree once; the example wraps only itself. */}
<RouterProvider
navigate={(href) => {
setCurrent(href.slice(1));
router.push(href, { scroll: false });
}}
>
<DataTable
aria-label="Transactions"
items={sorted(TRANSACTIONS.slice(0, 6), sort)}
columns={COLUMNS}
getKey={(row) => row.id}
getRowHref={(row) => `#${row.id}`}
currentKey={current}
sortDescriptor={sort}
onSortChange={setSort}
/>
</RouterProvider>
</div>
);
}Figures and totals
A column with align: 'end' sets figures: tabular digits, never broken, at the line's end in either direction, so a column of amounts lines up at its last digit. Your strings carry the sign, the currency and the unit; write a negative amount with a real minus sign, U+2212, which is as wide as a digit, as these examples do, not a hyphen. A column's total adds one totals row under the rows, its header cell saying "Total". When the table shows one page of many, the sum is of this page only: name it with labels={{ total: 'Page total' }}, so a reader never takes it for the whole. In a Card the sticky header takes the card's fill by itself; on any other fill, set --data-table-surface to it on an ancestor.
Example
Example
This week
| Date | Payee | Amount |
|---|---|---|
| 14 Oct | Corner Mart | −$42.18 |
| 14 Oct | City Transit | −$2.75 |
| 13 Oct | Northside Rent | −$1,450.00 |
| 13 Oct | Acme Payroll | $3,125.00 |
| 12 Oct | Green Grocer | −$28.90 |
| Total | $1,601.17 |
'use client';
import { Card, CardBody, CardHeader, CardTitle, DataTable, type DataTableColumn } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { amountLabel, dateLabel, totalLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const ROWS = TRANSACTIONS.slice(0, 5);
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
listSlot: 'end',
width: '30%',
total: totalLabel(ROWS),
},
];
export default function DataTableFigures(): ReactElement {
return (
<Card as="section" aria-labelledby="data-table-figures-title" style={{ width: '100%' }}>
<CardHeader>
<CardTitle id="data-table-figures-title" level={3}>
This week
</CardTitle>
</CardHeader>
<CardBody>
{/* In a card the sticky header takes the card's fill by itself. */}
<DataTable
aria-labelledby="data-table-figures-title"
items={ROWS}
columns={COLUMNS}
getKey={(row) => row.id}
/>
</CardBody>
</Card>
);
}Columns that leave
The table never scrolls sideways. A column with hideBelow: 'md' leaves when the table's own box is narrower than 48rem. It is a width of the table's box, not of the screen, so a table beside an open panel drops its columns too. Under 40rem the table gives way to its list, below, where each column goes where its listSlot says, so a column needs no width of its own to leave at. Cells keep to one line and end in an ellipsis; the full text is still the row's name. A cell that holds only a control, such as a row menu's button, is never clipped and draws no ellipsis; give its column a width that holds it, the control and 16px either side. The row header never leaves. The example draws the table at the width you choose, in a frame of its own that scrolls sideways where this page is narrower, so the page itself never does. A frame that scrolls is where a table's header sticks, and this page's header is not inside it, so the frame sets --page-sticky-top to 0px: do the same on any element of yours that scrolls, in either direction, around a table.
Example
Example
| Date | Payee | Category | Account | Amount |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | Checking | −$42.18 |
| 14 Oct | City Transit | Transport | Checking | −$2.75 |
| 13 Oct | Northside Rent | Rent | Checking | −$1,450.00 |
| 13 Oct | Acme Payroll | Salary | Checking | $3,125.00 |
| 12 Oct | Green Grocer | Groceries | Credit card | −$28.90 |
'use client';
import { DataTable, SegmentedControl, SegmentedControlItem, type DataTableColumn } from '@snapshot-ui/react';
import { useState, type CSSProperties, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{ id: 'account', header: 'Account', cell: (row) => row.account },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
width: '26%',
listSlot: 'end',
},
];
/** The widths of the table's box to try: over 48rem, under 48rem, then under 40rem, where it becomes its list. */
const WIDTHS: Record<string, string> = { wide: '56rem', medium: '44rem', narrow: '38rem' };
export default function DataTableColumns(): ReactElement {
const [width, setWidth] = useState('wide');
const box = WIDTHS[width] ?? '56rem';
return (
<div style={{ display: 'grid', gap: 'var(--spacing-peer)', width: '100%', minWidth: 0 }}>
<SegmentedControl label="Width of the table's box" value={width} onChange={setWidth}>
<SegmentedControlItem value="wide">56rem</SegmentedControlItem>
<SegmentedControlItem value="medium">44rem</SegmentedControlItem>
<SegmentedControlItem value="narrow">38rem</SegmentedControlItem>
</SegmentedControl>
{/* The table at the width chosen, in a frame that scrolls sideways on its own where the page is narrower,
so the page never does. The frame is a tab stop, so a keyboard scrolls it too. A frame that scrolls is
where the table's header sticks, and the page's header is not in it, so it sets the offset to 0. */}
<div
role="region"
aria-label="The table at the width chosen"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- a frame that scrolls must take focus to scroll by keyboard
tabIndex={0}
style={{ overflowX: 'auto', maxWidth: '100%', '--page-sticky-top': '0px' } as CSSProperties}
>
{/* A narrower box is drawn as a dashed outline inside its edge, so its width reads as chosen. */}
<div
data-testid="data-table-box"
style={{
width: box,
outline: width === 'wide' ? undefined : '1px dashed var(--color-frame)',
outlineOffset: -1,
}}
>
<DataTable
aria-label="Transactions"
items={TRANSACTIONS.slice(0, 5)}
columns={COLUMNS}
getKey={(row) => row.id}
/>
</div>
</div>
{width === 'wide' ? null : (
<p
style={{
margin: 0,
color: 'var(--color-slate)',
fontSize: 'var(--text-small)',
lineHeight: 'var(--text-small--line-height)',
}}
>
{`The table's box: ${box}`}
</p>
)}
</div>
);
}On a phone
Where the table's own box is narrower than 40rem, on a phone or beside an open panel on a small laptop, the same rows are a list. Both faces are in the page's HTML and the box shows one, so nothing is measured and the server's page is right at every width. A column's listSlot puts its cell on an item's first line ('title', the row header's place by default), on its second line in slate ('meta'), at its end ('end', figures as in the table) or nowhere ('hidden', the default for every other column); cells on one line sit a little apart, and any separator is yours. getGroup groups the rows under a heading per day: only rows next to each other group, so pass it only while the rows are sorted by date, and the day's label is your string. A row opens and is marked current as in the table, and the totals are a line under the list.
With selection on, the list draws no checkboxes until the reader presses Select above it: then every item has its box, Select all leads that row and the button reads Done, and that row sticks under your header while the list scrolls, so Select all and Done stay in reach. In Select mode a tap anywhere on an item selects or clears it, as a phone's mail and photos do; Enter, or Right to the item's link and Enter there, still opens it. Done hides the boxes and keeps the selection, and a tap opens an item again and never selects it. While anything is selected every item keeps room for a check mark, drawn beside the shade of each selected one, so nothing moves as items are selected, after Done or when they were selected in the table before the box narrowed. Space and X select an item whether the boxes show or not, and the bar is the table's. With more than one end column, the totals line names each figure by its column.
Example
Example
| Date | Payee | Category | Amount | |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 | |
| 14 Oct | City Transit | Transport | −$2.75 | |
| 13 Oct | Northside Rent | Rent | −$1,450.00 | |
| 13 Oct | Acme Payroll | Salary | $3,125.00 | |
| 12 Oct | Green Grocer | Groceries | −$28.90 | |
| 12 Oct | Bright Power | Utilities | −$86.40 | |
| 11 Oct | Cafe Luna | Dining | −$14.50 |
'use client';
import { Button, DataTable, RouterProvider, type DataTableColumn } from '@snapshot-ui/react';
import { useRouter } from 'next/navigation';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, dayLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
// The row header is an item's first line, its link stretched over the item.
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
// The second line, in slate.
{ id: 'category', header: 'Category', cell: (row) => row.category, listSlot: 'meta' },
// At the item's end, figures as in the table.
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
width: '26%',
listSlot: 'end',
},
];
export default function DataTablePhone(): ReactElement {
const router = useRouter();
const [current, setCurrent] = useState<string | null>('t3');
const [selected, setSelected] = useState<(string | number)[]>([]);
return (
// A box narrower than 40rem, as a phone's screen or a table beside an open panel: the list is drawn.
<div style={{ width: 'min(22rem, 100%)' }}>
<RouterProvider
navigate={(href) => {
setCurrent(href.slice(1));
router.push(href, { scroll: false });
}}
>
<DataTable
aria-label="Transactions"
// Sorted newest first, so each day's rows are adjacent and group under one heading.
items={TRANSACTIONS.slice(0, 7)}
columns={COLUMNS}
getKey={(row) => row.id}
getRowHref={(row) => `#${row.id}`}
getGroup={(row) => ({ id: row.date, label: dayLabel(row.date) })}
currentKey={current}
selectedKeys={selected}
onSelectionChange={setSelected}
bulkActions={<Button variant="secondary">Categorize</Button>}
/>
</RouterProvider>
</div>
);
}A gauge in a cell
A 32px Gauge fits a row in every density, for a share per row such as each card's utilization. The column header names the cell, so name each gauge with its row, "Utilization, Everyday Visa ending 4821", with aria-label, and let its value text show beside it. Give the column a textValue with the figure, and listSlot: 'meta' to keep the ring on a phone's list. Marks that repeat on every row take an empty label, so they are drawn and not read again per row; say the thresholds once by the table.
Example
Example
Utilization marks: best under 10%, and keep it under 30%.
| Card | Utilization | Balance |
|---|---|---|
| Everyday Visa ending 4821 | $1,150.00 | |
| Travel Mastercard ending 0937 | $2,350.00 | |
| Store card ending 5512 | $40.00 |
'use client';
import { DataTable, Gauge, type DataTableColumn } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
interface CardRow {
id: string;
name: string;
balance: string;
used: number;
}
const CARDS: CardRow[] = [
{ id: 'visa', name: 'Everyday Visa ending 4821', balance: '$1,150.00', used: 23 },
{ id: 'master', name: 'Travel Mastercard ending 0937', balance: '$2,350.00', used: 47 },
{ id: 'store', name: 'Store card ending 5512', balance: '$40.00', used: 4 },
];
/**
* The two thresholds, drawn on every row. Their words would be read again on each row, so the marks carry none
* and the note above the table says them once.
*/
const MARKS = [
{ value: 10, label: '' },
{ value: 30, label: '' },
];
const COLUMNS: readonly DataTableColumn<CardRow>[] = [
{ id: 'name', header: 'Card', cell: (card) => card.name, isRowHeader: true },
{
id: 'utilization',
header: 'Utilization',
// The column header names the cell, so the gauge takes the row's name and shows only its value text.
cell: (card) => (
<Gauge
aria-label={`Utilization, ${card.name}`}
size="sm"
value={card.used}
valueLabel={card.used > 30 ? `${String(card.used)}%, above 30%` : `${String(card.used)}%`}
tone={card.used > 30 ? 'warning' : 'neutral'}
marks={MARKS}
/>
),
textValue: (card) => `${String(card.used)}%`,
listSlot: 'meta',
},
{ id: 'balance', header: 'Balance', cell: (card) => card.balance, align: 'end', listSlot: 'end' },
];
export default function GaugeTable(): ReactElement {
return (
<div className="stack" style={{ width: '100%', gap: 'var(--spacing-part)' }}>
<p className="copy copy-small" style={{ margin: 0 }}>
Utilization marks: best under 10%, and keep it under 30%.
</p>
<DataTable aria-label="Cards" items={CARDS} columns={COLUMNS} getKey={(card) => card.id} />
</div>
);
}Beside a side panel
listBelow="32rem" moves the switch from 40rem of the table's box to 32rem, so a table beside an open Side panel keeps its table face at 1440px, with its hideBelow: 'md' columns gone, as under 48rem. Every phone held upright still draws the list, since a phone's box is under 32rem. Pass it when the columns that stay under 48rem fit in 32rem: a row header, a date, an amount and the checkbox column do. The select mode, the scroll clears and the sticky offsets follow the face drawn, and the server's HTML is right at every width, as before. The default, '40rem', is the switch the table has always had. The Transactions recipe below passes it: open a row and the table stays a table beside the panel.
Column options
A column with overflow: 'wrap' reads to two lines and ends in an ellipsis there, for a memo a reader wants to read without opening the row; figures never wrap. firstSortDirection: 'descending' makes the first press on a column sort it largest or newest first, and each later press flips it, two states only. A column of row menus takes isHeaderHidden: its header draws nothing and is still named by header for a screen reader.
A cell can hold a second line under its text, such as a payee's category: write it in a span with the class data-table-cell-meta, which sets it slate at the cell weight, a quarter rem under the first line, and granite in a hovered or selected row so it keeps its contrast on the wash. Each line ends in an ellipsis when it runs out of room. A row of two lines is 72px, with its text as far off the rules as one line is in a 48px row, and the one-line cells beside it stay centered.
const payee: DataTableColumn<Transaction> = {
id: 'payee',
header: 'Payee',
isRowHeader: true,
cell: (row) => (
<>
{row.payee}
<span className="data-table-cell-meta">{row.category}</span>
</>
),
};Example
Example
| Date | Payee | Memo | Amount | Actions |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries from checking, matched to the October statement and the receipt the bank sent | −$42.18 | |
| 14 Oct | City Transit | Transport from checking, matched to the October statement and the receipt the bank sent | −$2.75 | |
| 13 Oct | Northside Rent | Rent from checking, matched to the October statement and the receipt the bank sent | −$1,450.00 | |
| 13 Oct | Acme Payroll | Salary from checking, matched to the October statement and the receipt the bank sent | $3,125.00 |
'use client';
import { DataTable, IconButton, type DataTableColumn } from '@snapshot-ui/react';
import { Ellipsis } from 'lucide-react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
interface Sort {
column: string;
direction: 'ascending' | 'descending';
}
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '16%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true, width: '22%' },
{
id: 'memo',
header: 'Memo',
// A memo a reader wants to read without opening the row: two lines, then an ellipsis.
cell: (row) =>
`${row.category} from ${row.account.toLowerCase()}, matched to the October statement and the receipt the bank sent`,
overflow: 'wrap',
// On a phone, the item's second line, wrapped to two lines there too.
listSlot: 'meta',
},
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
listSlot: 'end',
width: '20%',
allowsSorting: true,
// Largest first on the first press.
firstSortDirection: 'descending',
},
{
id: 'actions',
// Named for a screen reader, drawn as nothing.
header: 'Actions',
isHeaderHidden: true,
// The 40px button and the cell's 16px either side.
width: '4.5rem',
cell: (row) => (
<IconButton aria-label={`More for ${row.payee}`}>
<Ellipsis />
</IconButton>
),
},
];
export default function DataTableColumnOptions(): ReactElement {
const [sort, setSort] = useState<Sort | undefined>(undefined);
const rows = [...TRANSACTIONS.slice(0, 4)].sort((a, b) =>
sort === undefined ? 0 : sort.direction === 'ascending' ? a.minor - b.minor : b.minor - a.minor,
);
return (
<div style={{ width: '100%' }}>
<DataTable
aria-label="Transactions"
items={rows}
columns={COLUMNS}
getKey={(row) => row.id}
sortDescriptor={sort}
onSortChange={setSort}
/>
</div>
);
}Empty, loading and errors
emptyState takes an EmptyState, drawn across every column when there are no rows; the empty table is then itself the tab stop and draws the focus ring. While isLoading and there are no rows yet, the table draws skeleton rows in its own shape and says "Loading" once, after a short pause, in a status of its own. While it loads with rows already shown, such as the next page, it keeps them, marks itself busy and, after the same pause, draws a quiet line along its header's rule, or in the list one line at the top, under the first day's heading; no text dims. When the rows did not load, pass error: with no rows it is the body, in place of the empty state, as an EmptyState with a Retry action; with rows shown, a next page that failed, it is a line under the table that a polite status says. error wins over isLoading and emptyState.
Example
Example
| Date | Payee | Category | Amount |
|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 |
| 14 Oct | City Transit | Transport | −$2.75 |
| 13 Oct | Northside Rent | Rent | −$1,450.00 |
| 13 Oct | Acme Payroll | Salary | $3,125.00 |
'use client';
import {
Button,
DataTable,
EmptyState,
SegmentedControl,
SegmentedControlItem,
type DataTableColumn,
} from '@snapshot-ui/react';
import { Receipt, RotateCw } from 'lucide-react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
width: '26%',
listSlot: 'end',
},
];
export default function DataTableStates(): ReactElement {
const [state, setState] = useState('rows');
return (
<div style={{ display: 'grid', gap: 'var(--spacing-peer)', width: '100%' }}>
<SegmentedControl label="State" value={state} onChange={setState}>
<SegmentedControlItem value="rows">Rows</SegmentedControlItem>
<SegmentedControlItem value="empty">Empty</SegmentedControlItem>
<SegmentedControlItem value="loading">Loading</SegmentedControlItem>
<SegmentedControlItem value="error">Error</SegmentedControlItem>
</SegmentedControl>
<DataTable
aria-label="Transactions"
items={state === 'rows' ? TRANSACTIONS.slice(0, 4) : []}
columns={COLUMNS}
getKey={(row) => row.id}
isLoading={state === 'loading'}
loadingRowCount={4}
error={
state === 'error' ? (
// The rows did not load: the error is the body, with the one thing a reader can do about it.
<EmptyState
icon={<RotateCw />}
title="The transactions did not load"
description="Check your connection, then try again."
action={
<Button
variant="secondary"
onPress={() => {
setState('rows');
}}
>
Retry
</Button>
}
/>
) : undefined
}
emptyState={
<EmptyState
icon={<Receipt />}
title="No transactions this month"
description="Transactions you add or import show here."
action={<Button variant="secondary">Import transactions</Button>}
/>
}
/>
</div>
);
}Filtered to nothing
When filters or a search hide every row, the table is empty for a different reason than a month with no transactions: say what happened, and offer the way back with a Clear filters action.
Example
Example
| Date | Payee | Category | Amount |
|---|---|---|---|
No transactions matchTry another search, or clear the filters to see every transaction. | |||
No transactions match
Try another search, or clear the filters to see every transaction.
'use client';
import { Button, DataTable, EmptyState, SearchField, type DataTableColumn } from '@snapshot-ui/react';
import { SearchX } from 'lucide-react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
width: '26%',
listSlot: 'end',
},
];
export default function DataTableFiltered(): ReactElement {
// A filter that matches nothing yet, so the example opens on what a reader sees then.
const [query, setQuery] = useState('Gym');
const needle = query.trim().toLowerCase();
const rows = TRANSACTIONS.slice(0, 6).filter(
(row) => needle === '' || `${row.payee} ${row.category}`.toLowerCase().includes(needle),
);
return (
<div style={{ display: 'grid', gap: 'var(--spacing-peer)', width: '100%' }}>
<SearchField label="Search transactions" value={query} onChange={setQuery} />
<DataTable
aria-label="Transactions"
items={rows}
columns={COLUMNS}
getKey={(row) => row.id}
emptyState={
// Rows exist and the filters hide them all: say so, and offer the way back.
<EmptyState
icon={<SearchX />}
title="No transactions match"
description="Try another search, or clear the filters to see every transaction."
action={
<Button
variant="secondary"
onPress={() => {
setQuery('');
}}
>
Clear filters
</Button>
}
/>
}
/>
</div>
);
}Selection
Pass selectedKeys and onSelectionChange together to turn selection on: a first column of checkboxes, and a bar of bulk actions that sticks to the bottom of the screen while any row is selected. Hold the selection in your page's state, never in the URL, and clear it whenever the rows change, on a new page, sort or filter, as this example clears it on a sort. A press on a row still opens it, and a press on a row without a link does nothing; only the checkbox, Space and X select, and Shift with a press on a checkbox selects every row from the last one chosen. disabledKeys turns a row's selection off, and with disabledBehavior: 'selection' only that: the row still opens and reads as any other. The header checkbox selects the rows on this page, and onSelectionChange always gets an array of their keys, never "all": to act on every result, offer that in the bar yourself, through selectAllMatching. The bar starts with an X that clears the selection, named Clear selection, then the count; bulkActions are your actions, as data the bar lays out (below) or as your own buttons, and formatCount writes the bar's count in your reader's digits. The bar counts only selected keys that are still in items, and the table draws only those as selected; a key that is not stays in what onSelectionChange gets until you drop it, and is warned about once in the console. Clear selection clears every key. When a bulk action asks first, in a dialog whose confirm clears the selection, the table takes focus back once the dialog has closed. A table inside a dialog keeps its keys.
Example
Example
| Date | Payee | Category | Amount | |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 | |
| 14 Oct | City Transit | Transport | −$2.75 | |
| 13 Oct | Northside Rent | Rent | −$1,450.00 | |
| 13 Oct | Acme Payroll | Salary | $3,125.00 | |
| 12 Oct | Green Grocer | Groceries | −$28.90 | |
| 12 Oct | Bright Power | Utilities | −$86.40 |
'use client';
import { Button, DataTable, RouterProvider, type DataTableColumn } from '@snapshot-ui/react';
import { useRouter } from 'next/navigation';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
interface Sort {
column: string;
direction: 'ascending' | 'descending';
}
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), allowsSorting: true, width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
listSlot: 'end',
allowsSorting: true,
width: '26%',
},
];
/** Sorts a copy of the rows as the app's server would, by the URL's sort. */
function sorted(rows: readonly Transaction[], sort: Sort): Transaction[] {
return [...rows].sort((a, b) => {
const order = sort.column === 'amount' ? a.minor - b.minor : a.date.localeCompare(b.date);
return sort.direction === 'ascending' ? order : -order;
});
}
export default function DataTableSelection(): ReactElement {
const router = useRouter();
const [sort, setSort] = useState<Sort>({ column: 'date', direction: 'descending' });
// Selection lives in the page's state, never the URL, and is cleared whenever the rows change.
const [selected, setSelected] = useState<(string | number)[]>([]);
const [current, setCurrent] = useState<string | null>(null);
return (
<div style={{ width: '100%' }}>
<RouterProvider
navigate={(href) => {
setCurrent(href.slice(1));
router.push(href, { scroll: false });
}}
>
<DataTable
aria-label="Transactions"
items={sorted(TRANSACTIONS.slice(0, 6), sort)}
columns={COLUMNS}
getKey={(row) => row.id}
getRowHref={(row) => `#${row.id}`}
currentKey={current}
sortDescriptor={sort}
onSortChange={(next) => {
setSort(next);
setSelected([]);
}}
selectedKeys={selected}
onSelectionChange={setSelected}
bulkActions={
<>
<Button variant="secondary">Categorize</Button>
<Button
variant="danger"
onPress={() => {
// The app deletes the rows, then clears the selection; the table puts focus back on itself.
setSelected([]);
}}
>
Delete
</Button>
</>
}
/>
</RouterProvider>
</div>
);
}Bulk actions
Pass bulkActions as data and the bar lays them out on one line that never wraps: the actions with isPromoted, three at most, as buttons with their icons, and the rest in a More menu at the bar's end, named "More actions" (labels.moreActions). Each action's onAction gets the selected keys that are in items. More reads which buttons are drawn as it opens, so where the bar has less than 40rem of room for its controls, inside its own padding, on a phone or in a narrow column, the buttons step aside and More holds every action; where every action is promoted and the bar is wide, there is no More. The padding follows the shape, so the bar itself switches at a few pixels more than 40rem in Rounded than in Square. A fourth promoted action goes to More with a console warning.
selectAllMatching is a place after the count for your "Select all 24 matching", as here once every row on the page is selected: the table selects only the rows it draws, so acting on every match is yours. On the narrowest bar it takes a line of its own. When your button leaves, as this one does once pressed, focus moves to the next control in the bar, never to the page; so does focus on a button the bar hides as it narrows, which moves to More.
bulkActions still takes your own buttons as a node, drawn as they are after the count, so nothing breaks; the bar's X is drawn either way. The bar writes its height as --save-bar-offset once scripts run, as Save bar does, so the upload tray and the toasts sit above it; with a save bar on the page too, the taller wins.
Example
Example
| Date | Payee | Category | Amount | |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 | |
| 14 Oct | City Transit | Transport | −$2.75 | |
| 13 Oct | Northside Rent | Rent | −$1,450.00 | |
| 13 Oct | Acme Payroll | Salary | $3,125.00 | |
| 12 Oct | Green Grocer | Groceries | −$28.90 | |
| 12 Oct | Bright Power | Utilities | −$86.40 |
'use client';
import {
Button,
DataTable,
RouterProvider,
type DataTableBulkAction,
type DataTableColumn,
type Key,
} from '@snapshot-ui/react';
import { Archive, Download, Tag, Trash2 } from 'lucide-react';
import { useState, type ReactElement } from 'react';
import { amountLabel, dateLabel, TRANSACTIONS, type Transaction } from './transactions-data';
const COLUMNS: readonly DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
listSlot: 'end',
width: '26%',
},
];
/** What the search matched on the server: more than the page shows. */
const MATCHING = 24;
/** "1 transaction" or "3 transactions", written by the app. */
const transactions = (count: number): string =>
count === 1 ? '1 transaction' : `${String(count)} transactions`;
export default function DataTableBulkActions(): ReactElement {
const rows = TRANSACTIONS.slice(0, 6);
const [selected, setSelected] = useState<Key[]>(['t2', 't4']);
// "Select all 24 matching" is the app's: the table selects only the rows it draws.
const [allMatching, setAllMatching] = useState(false);
const [done, setDone] = useState('');
const select = (keys: Key[]): void => {
setSelected(keys);
setAllMatching(false);
};
/** Each action reports what it did; a real app sends the keys, or the search when every match is chosen. */
const run =
(verb: string) =>
(keys: Key[]): void => {
setDone(`${verb} ${transactions(allMatching ? MATCHING : keys.length)}`);
};
const actions: DataTableBulkAction[] = [
{ id: 'categorize', label: 'Categorize', icon: <Tag />, isPromoted: true, onAction: run('Categorized') },
{ id: 'export', label: 'Export', icon: <Download />, isPromoted: true, onAction: run('Exported') },
{ id: 'archive', label: 'Archive', icon: <Archive />, onAction: run('Archived') },
{
id: 'delete',
label: 'Delete',
icon: <Trash2 />,
onAction: (keys) => {
run('Deleted')(keys);
// The app deletes the rows, then clears the selection; the table puts focus back on itself.
select([]);
},
},
];
return (
<div style={{ display: 'grid', gap: 'var(--spacing-part)', width: '100%' }}>
<RouterProvider navigate={() => undefined}>
<DataTable
aria-label="Transactions"
items={rows}
columns={COLUMNS}
getKey={(row) => row.id}
selectedKeys={selected}
onSelectionChange={select}
bulkActions={actions}
selectAllMatching={
selected.length === rows.length ? (
<>
{allMatching ? null : (
<Button
variant="tertiary"
onPress={() => {
setAllMatching(true);
}}
>
{`Select all ${String(MATCHING)} matching`}
</Button>
)}
{/* Mounted empty beside the button, so its sentence is announced as the button leaves. */}
<span role="status">
{allMatching ? `All ${String(MATCHING)} matching are selected` : ''}
</span>
</>
) : null
}
/>
</RouterProvider>
<p role="status" style={{ margin: 0, color: 'var(--color-slate)', fontSize: 'var(--text-small)' }}>
{done}
</p>
</div>
);
}The Transactions recipe
A list page with the Next App Router, its state in the URL: the sort, the page, the filters and the open transaction. The server page awaits searchParams, loads the rows for the sort, the page and the filters, and loads the open transaction too, so its detail is in the server's HTML beside the table. It passes them to a client component, since DataTable's props are functions:
// app/transactions/page.tsx, a server component
import { TransactionsList } from './transactions-list';
const PAGE_SIZES = [25, 50, 100];
export default async function TransactionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const one = (value: string | string[] | undefined) => (typeof value === 'string' ? value : null);
// ?sort=amount is ascending and ?sort=amount- descending; no sort is the newest first.
const sort = one(params.sort) ?? 'date-';
const page = Math.max(1, Math.trunc(Number(one(params.page) ?? '1')) || 1);
// ?size= is one of the sizes the picker offers; anything else is 50.
const pageSize = PAGE_SIZES.find((size) => String(size) === one(params.size)) ?? 50;
const category = one(params.category);
const openId = one(params.open);
const { rows, total } = await loadTransactions({ sort, page, category, pageSize });
const open = openId === null ? null : await loadTransaction(openId);
return (
<TransactionsList
rows={rows}
pageCount={Math.max(1, Math.ceil(total / pageSize))}
pageSize={pageSize}
rangeLabel={formatRange(page, pageSize, total)}
sort={sort}
page={page}
category={category}
open={open}
/>
);
}The client component writes the URL with the router, starting from the parameters already there, so a sort keeps the filters and the open row. A sort and a filter are replaced in history and start again at page 1; a page is pushed, so Back returns to the last page. A new page size is replaced too, on the page that keeps the reader's first row in view, which Pagination works out. Pagination's picker calls a function, which is why it is rendered here, in the client component. Selection is the page's own state and never the URL's, and it clears whenever the rows change, on a new page, sort or filter: it was made on the rows before. The component clears it as it renders the new rows, so no frame shows the old selection, and the bar never counts a row that has left.
'use client';
import { DataTable, Pagination, SidePanel, SidePanelLayout, type Key } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export function TransactionsList({ rows, pageCount, pageSize, rangeLabel, sort, page, category, open }: TransactionsListProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// The URL with some parameters changed or deleted; a bare ? is never written.
const hrefWith = (changes: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams);
for (const [name, value] of Object.entries(changes)) {
if (value === null) params.delete(name);
else params.set(name, value);
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
};
const replace = (changes: Record<string, string | null>) => {
router.replace(hrefWith(changes), { scroll: false });
};
const descriptor = {
column: sort.replace(/-$/, ''),
direction: sort.endsWith('-') ? 'descending' : 'ascending',
} as const;
// A new page, sort or filter shows other rows: the selection was made on the rows before, so it clears.
const shown = rows.map((row) => row.id).join(' ');
const [selected, setSelected] = useState<Key[]>([]);
const [selectedOn, setSelectedOn] = useState(shown);
if (selectedOn !== shown) {
setSelectedOn(shown);
setSelected([]);
}
return (
<SidePanelLayout
panel={
<SidePanel
key={open?.id ?? 'none'}
title={open?.payee ?? ''}
description={open?.dayLabel}
isOpen={open !== null}
onClose={() => closePanel(hrefWith({ open: null }))}
>
{open && <TransactionDetail transaction={open} />}
</SidePanel>
}
>
<h1 id="transactions-title">Transactions</h1>
<TransactionFilters category={category} onChange={(next) => replace({ category: next, page: null })} />
<DataTable
aria-labelledby="transactions-title"
items={rows}
columns={columns}
getKey={(row) => row.id}
getRowHref={(row) => hrefWith({ open: row.id })}
currentKey={open?.id ?? null}
sortDescriptor={descriptor}
onSortChange={(next) => {
const written = `${String(next.column)}${next.direction === 'descending' ? '-' : ''}`;
replace({ sort: written === 'date-' ? null : written, page: null });
}}
// Sorted by date, each day's rows are next to each other: the list groups them under the day.
getGroup={descriptor.column === 'date' ? (row) => ({ id: row.date, label: row.dayLabel }) : undefined}
selectedKeys={selected}
onSelectionChange={setSelected}
bulkActions={[
{ id: 'export', label: 'Export', icon: <Download />, isPromoted: true, onAction: exportRows },
{ id: 'delete', label: 'Delete', icon: <Trash2 />, onAction: confirmDelete },
]}
// The date, the payee and the amount fit in 32rem: the table keeps its face beside the panel.
listBelow="32rem"
labels={{ total: 'Page total' }}
/>
<Pagination
page={page}
pageCount={pageCount}
rangeLabel={rangeLabel}
getPageHref={(n) => hrefWith({ page: n === 1 ? null : String(n) })}
pageSize={pageSize}
// The page Pagination works out keeps the first row of this page in view at the new size.
onPageSizeChange={(size, next) =>
replace({ size: size === 50 ? null : String(size), page: next === 1 ? null : String(next) })
}
/>
</SidePanelLayout>
);
}- Wrap the app once in a
RouterProviderwhosenavigateisrouter.push(href, { scroll: false }), so the row links and the page links go through the router and the page never jumps. - Opening a row from the list pushes an entry, so Back closes the panel. Close the panel the same way when your page pushed that entry: with
router.back(), so no second entry with the list's URL is left for Back to land on. A row that arrived with the page, from a link or a reload, closes withrouter.replace. Opening another row while one is open replaces the open row's entry, so Back and Close still return to the list, as the example does:
const opened = useRef<string | null>(null); // the entry this page pushed as it opened a row
const here = () => `${window.location.pathname}${window.location.search}`;
const navigate = (href: string) => {
const before = new URLSearchParams(window.location.search).get('open');
const after = new URL(href, window.location.href).searchParams.get('open');
if (before !== null && after !== null && after !== before) {
if (opened.current === here()) opened.current = href;
router.replace(href, { scroll: false });
} else {
if (before === null && after !== null) opened.current = href;
router.push(href, { scroll: false });
}
};
const closePanel = (closedHref: string) => {
if (opened.current === here()) {
opened.current = null;
router.back();
} else {
router.replace(closedHref, { scroll: false });
}
};- Put your page's header in App shell's
headerslot: the shell measures it and writes its height as--page-sticky-top, and the table's header, the list's day headings and the panel stick under it. Set--page-sticky-topyourself only for a header outside the shell, on an ancestor, such asstyle={{ '--page-sticky-top': '4rem' }}. - Start the
SidePanelLayoutright under your header, with the title, the filters, the table and the pager in its content, so the panel's footer is in view from the start. In an AppShell under 1440px, the open panel folds the rail to its icons, so the table keeps its columns. - Pass
getGrouponly while the rows are sorted by date: only rows next to each other group. - Strings are yours: the dates, the amounts, the day headings and the range. React Aria announces a sort and a change of selection in its own English words, even on a Persian page; the bar's count is yours, through
formatCount.
The example below is the same page in a client component, since this site is static: it reads the URL in the browser, so here the open row's detail appears once scripts run, where an app's server page draws it at once. A 4rem header stands in for your app's, under this site's own, and the offset is both. Sort by amount, go to page 2 and open a row, then reload: the URL holds all three. Choose 10 rows a page and the URL holds that too. While the panel is open this site folds its menu to its icons under 1640px, as AppShell folds an app's rail under 1440px, and from 1440px puts its outline of the page under the content, since the examples sit in a narrower column than an app's content: open a row at 1440px and the table keeps its face beside the panel, as an app's does. At 1280px the table's box beside the panel is under 40rem, and listBelow="32rem" is what keeps the face.
Example
Example
Transactions
| Date | Payee | Category | Amount | |
|---|---|---|---|---|
| 14 Oct | Corner Mart | Groceries | −$42.18 | |
| 14 Oct | City Transit | Transport | −$2.75 | |
| 13 Oct | Northside Rent | Rent | −$1,450.00 | |
| 13 Oct | Acme Payroll | Salary | $3,125.00 | |
| 12 Oct | Green Grocer | Groceries | −$28.90 | |
| Page total | $1,601.17 |
'use client';
import {
Button,
DataTable,
EmptyState,
FilterBar,
FilterChip,
Heading,
Pagination,
PickerOption,
RouterProvider,
SidePanel,
SidePanelLayout,
type DataTableBulkAction,
type DataTableColumn,
type Key,
} from '@snapshot-ui/react';
import { Download, SearchX, Tag, Trash2 } from 'lucide-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useId, useRef, useState, type CSSProperties, type ReactElement } from 'react';
import {
amountLabel,
dateLabel,
dayLabel,
totalLabel,
TRANSACTIONS,
type Transaction,
} from './transactions-data';
/** The rows a page shows: `?size=` holds another of PAGE_SIZES; the first writes no parameter. */
const PAGE_SIZES = [5, 10, 25] as const;
const PAGE_SIZE = PAGE_SIZES[0];
/** The page size the URL asks for, if it is one the page offers. */
function sizeOf(raw: string | null): number {
const asked = Number(raw ?? PAGE_SIZE);
return PAGE_SIZES.find((size) => size === asked) ?? PAGE_SIZE;
}
interface Sort {
column: 'date' | 'amount';
direction: 'ascending' | 'descending';
}
/** The categories the chip offers, by the id the URL holds. */
const CATEGORIES: Record<string, string> = {
groceries: 'Groceries',
transport: 'Transport',
rent: 'Rent',
dining: 'Dining',
};
/** `?sort=amount` is ascending and `?sort=amount-` descending; no `sort` is the newest first. */
function sortOf(raw: string | null): Sort {
const descending = raw === null || raw.endsWith('-');
const column = raw?.replace(/-$/, '') === 'amount' ? 'amount' : 'date';
return { column, direction: descending ? 'descending' : 'ascending' };
}
/** What the server does with the URL: filter, sort and cut one page, as a query would. */
function rowsFor(params: URLSearchParams): {
rows: Transaction[];
sort: Sort;
page: number;
pageCount: number;
pageSize: number;
total: number;
} {
const category = CATEGORIES[params.get('category') ?? ''];
const sort = sortOf(params.get('sort'));
const pageSize = sizeOf(params.get('size'));
const matching = TRANSACTIONS.filter((row) => category === undefined || row.category === category).sort(
(a, b) => {
const order = sort.column === 'amount' ? a.minor - b.minor : a.date.localeCompare(b.date);
return sort.direction === 'ascending' ? order : -order;
},
);
const pageCount = Math.max(1, Math.ceil(matching.length / pageSize));
const asked = Number(params.get('page') ?? '1');
const page = Number.isInteger(asked) && asked >= 1 && asked <= pageCount ? asked : 1;
const rows = matching.slice((page - 1) * pageSize, page * pageSize);
return { rows, sort, page, pageCount, pageSize, total: matching.length };
}
/** The columns, with the amount's total for the rows shown: one page's, so the totals row says "Page total". */
const columnsFor = (rows: readonly Transaction[]): DataTableColumn<Transaction>[] => [
{ id: 'date', header: 'Date', cell: (row) => dateLabel(row.date), allowsSorting: true, width: '18%' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{
id: 'amount',
header: 'Amount',
cell: (row) => amountLabel(row.minor),
align: 'end',
allowsSorting: true,
width: '26%',
listSlot: 'end',
total: totalLabel(rows),
},
];
/** A transaction's fields, as the detail shows them. */
function Detail({ row }: { row: Transaction }): ReactElement {
const fields: [string, string][] = [
['Category', row.category],
['Account', row.account],
['Amount', amountLabel(row.minor)],
];
return (
<dl style={{ display: 'grid', gap: 'var(--spacing-part)', margin: 0 }}>
{fields.map(([term, value]) => (
<div key={term} style={{ display: 'grid', gap: '0.25rem' }}>
<dt style={{ color: 'var(--color-slate)', fontSize: 'var(--text-small)' }}>{term}</dt>
<dd style={{ margin: 0 }}>{value}</dd>
</div>
))}
</dl>
);
}
/** The list page, drawn from the URL's parameters: the server's page in an app, a client one here. */
interface TransactionsProps {
params: URLSearchParams;
/** Closes the panel, given the URL without `open`. */
close: (closedHref: string) => void;
}
function Transactions({ params, close }: TransactionsProps): ReactElement {
const router = useRouter();
const pathname = usePathname();
const titleId = useId();
const { rows, sort, page, pageCount, pageSize, total } = rowsFor(params);
const openId = params.get('open');
const open = TRANSACTIONS.find((row) => row.id === openId) ?? null;
/** The URL with one parameter changed, starting from the ones already there; never a bare `?`. */
const hrefWith = (changes: Record<string, string | null>): string => {
const next = new URLSearchParams(params);
for (const [name, value] of Object.entries(changes)) {
if (value === null) next.delete(name);
else next.set(name, value);
}
const query = next.toString();
return query ? `${pathname}?${query}` : pathname;
};
const replace = (changes: Record<string, string | null>): void => {
router.replace(hrefWith(changes), { scroll: false });
};
// Selection is the page's own state, never the URL's. A new page, sort or filter shows other rows: the selection
// was made on the rows before, so it clears, as the reader moves on.
const shown = rows.map((row) => row.id).join(' ');
const [selected, setSelected] = useState<Key[]>([]);
const [selectedOn, setSelectedOn] = useState(shown);
if (selectedOn !== shown) {
setSelectedOn(shown);
setSelected([]);
}
const first = total === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(page * pageSize, total);
const category = params.get('category');
const clearFilters = (): void => {
replace({ category: null, page: null });
};
// The bar's actions as data: Export and Categorize as buttons while the bar has room, Delete in More. An app
// sends the selected keys to its server; here each one clears the selection, as the work is done.
const done = (): void => {
setSelected([]);
};
const bulkActions: DataTableBulkAction[] = [
{ id: 'export', label: 'Export', icon: <Download />, isPromoted: true, onAction: done },
{ id: 'categorize', label: 'Categorize', icon: <Tag />, isPromoted: true, onAction: done },
{ id: 'delete', label: 'Delete', icon: <Trash2 />, onAction: done },
];
return (
<SidePanelLayout
panel={
// Keyed by the open row, so another row is a new opening and focus moves to its heading.
<SidePanel
key={openId ?? 'none'}
title={open?.payee ?? ''}
description={open === null ? undefined : dayLabel(open.date)}
isOpen={open !== null}
onClose={() => {
close(hrefWith({ open: null }));
}}
>
{open === null ? null : <Detail row={open} />}
</SidePanel>
}
>
<div style={{ display: 'grid', gap: 'var(--spacing-peer)' }}>
<Heading level={3} id={titleId}>
Transactions
</Heading>
<FilterBar
labels={{ done: total === 1 ? 'Show 1 result' : `Show ${String(total)} results` }}
onClearAll={clearFilters}
>
<FilterChip
label="Category"
selectionMode="single"
value={category !== null && category in CATEGORIES ? category : null}
onChange={(next) => {
replace({ category: next === null ? null : String(next), page: null });
}}
>
{Object.entries(CATEGORIES).map(([id, name]) => (
<PickerOption key={id} id={id}>
{name}
</PickerOption>
))}
</FilterChip>
</FilterBar>
<DataTable
aria-labelledby={titleId}
// Its columns that stay under 48rem, the date, the payee and the amount, fit in 32rem, so the table
// keeps its face beside the open panel.
listBelow="32rem"
items={rows}
columns={columnsFor(rows)}
getKey={(row) => row.id}
getRowHref={(row) => hrefWith({ open: row.id })}
currentKey={openId}
sortDescriptor={sort}
onSortChange={(next) => {
const column = next.column === 'amount' ? 'amount' : 'date';
const written = `${column}${next.direction === 'descending' ? '-' : ''}`;
// The newest first is the page's default, so it writes no parameter; a new sort starts at page 1.
replace({ sort: written === 'date-' ? null : written, page: null });
}}
// Sorted by date, each day's rows are next to each other, so the list groups them under the day.
getGroup={
sort.column === 'date' ? (row) => ({ id: row.date, label: dayLabel(row.date) }) : undefined
}
selectedKeys={selected}
onSelectionChange={setSelected}
bulkActions={bulkActions}
labels={{ total: 'Page total' }}
emptyState={
<EmptyState
icon={<SearchX />}
title="No transactions match"
description="Clear the filters to see every transaction."
action={
<Button variant="secondary" onPress={clearFilters}>
Clear filters
</Button>
}
/>
}
/>
<Pagination
page={page}
pageCount={pageCount}
rangeLabel={`${String(first)} to ${String(last)} of ${String(total)}`}
getPageHref={(n) => hrefWith({ page: n === 1 ? null : String(n) })}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZES}
// A new size keeps the first row in view on the page Pagination works out, and is replaced in history,
// as a sort is; the first size and the first page write no parameter.
onPageSizeChange={(size, nextPage) => {
replace({
size: size === PAGE_SIZE ? null : String(size),
page: nextPage === 1 ? null : String(nextPage),
});
}}
labels={{ navigation: 'Transactions pages' }}
/>
</div>
</SidePanelLayout>
);
}
function TransactionsFromUrl({ close }: Pick<TransactionsProps, 'close'>): ReactElement {
const params = useSearchParams();
return <Transactions params={new URLSearchParams(params)} close={close} />;
}
/** The first state: no parameters, as the static page's server renders it. */
const FIRST = new URLSearchParams();
/** The page's URL as the browser shows it, in the form `hrefWith` writes. */
const here = (): string => `${window.location.pathname}${window.location.search}`;
export default function DataTableTransactions(): ReactElement {
const router = useRouter();
// The entry this page pushed as it opened a row, so closing goes back to the list's entry rather than leave a
// copy of it behind for Back. A row that arrived with the page, from a link or a reload, closes in place.
const opened = useRef<string | null>(null);
const close = (closedHref: string): void => {
if (opened.current === here()) {
opened.current = null;
router.back();
} else {
router.replace(closedHref, { scroll: false });
}
};
return (
// Your header's height on an ancestor, once, so the table's header, the list's day headings and the panel
// stick under it. Here a 4rem header sits under this site's own, so the offset is both.
<div style={{ width: '100%', '--site-header': 'var(--page-sticky-top, 0px)' } as CSSProperties}>
<div style={{ '--page-sticky-top': 'calc(var(--site-header) + 4rem)' } as CSSProperties}>
<div
style={{
position: 'sticky',
top: 'var(--site-header)',
zIndex: 'var(--z-header)',
display: 'flex',
alignItems: 'center',
blockSize: '4rem',
marginBlockEnd: 'var(--spacing-inset)',
borderBlockEnd: '1px solid var(--color-divider)',
backgroundColor: 'var(--color-canvas)',
color: 'var(--color-slate)',
fontSize: 'var(--text-small)',
}}
>
Your app's header, 4rem tall
</div>
{/* An app wraps its whole tree once; the example wraps only itself. */}
<RouterProvider
navigate={(href) => {
const before = new URLSearchParams(window.location.search).get('open');
const after = new URL(href, window.location.href).searchParams.get('open');
if (before !== null && after !== null && after !== before) {
// Another row while one is open takes the open row's entry, so Back and Close return to the list.
if (opened.current === here()) opened.current = href;
router.replace(href, { scroll: false });
return;
}
if (before === null && after !== null) opened.current = href;
router.push(href, { scroll: false });
}}
>
{/* The page is static, so the server renders the fallback: the same page in its first state. */}
<Suspense fallback={<Transactions params={FIRST} close={close} />}>
<TransactionsFromUrl close={close} />
</Suspense>
</RouterProvider>
</div>
</div>
);
}Usage
Render DataTable from a client component: its columns, keys and links are functions, which a server component cannot pass. Load the rows in a server page and pass them as data; a node a server rendered can reach a cell as a field of its row. Wrap the app in a RouterProvider once, so the row links go through your router. currentKey is compared with each row's key by strict equality, so a key read from the URL, which is a string, never matches a numeric key: convert it first, as Number(params.get('open')). The row's link is stretched over the whole row, so text in a row cannot be selected by dragging across it; put text a reader copies in the detail the row opens. A control in a cell takes its own press above the row's link: a button, a link, an input, a label, a field, anything with a control role such as checkbox, switch or menuitem, and anything in the tab order. Give anything else that takes a press the class data-table-control. The table's header sticks at --page-sticky-top, which AppShell's header slot writes from the header's measured height once the page has loaded; set it yourself, on an ancestor, only for a header outside the shell. This site sets it from its own header, which is why the examples' headers stop under it. A row that focus reaches keeps clear of the sticky header and of the bulk-action bar. The bulk-action bar sticks above --app-shell-inset-block-end, which AppShell sets for its bottom tab bar. A table inside an element of yours that scrolls, a frame, a panel or a pane, sticks its header inside that element: set --page-sticky-top to 0px on it, unless your header scrolls inside it too, which DataTable cannot tell. Every string in the table is yours: the library formats no date, amount or count.
The table is a size container, so it takes its width from its holder, never from its content, and has no width of its own. A block, a 1fr grid track or a flex row gives it one: in a flex row it takes the room its siblings leave. A holder that sizes it from its content, such as an auto grid track, a flex column with align-items: flex-start, an inline-block or width: max-content, must give it a width, such as inline-size: 100%, or it draws 0 wide. A Meter in a row cuts its segments in that row's own fill, hovered and selected too: the table sets --meter-surface for you, from --data-table-surface, which a Card sets to its fill.
Both faces are in the page's HTML, so a page's weight grows with its rows: show about 50 rows a page, with Pagination under the table. Fifty rows of five columns, grouped by day, are about 130 kB of HTML for both faces, about 7 kB once compressed, as servers send it; 200 rows are about 480 kB, 20 kB compressed, and with selection on about 700 kB.
'use client';
import { DataTable, EmptyState, type DataTableColumn } from '@snapshot-ui/react';
const columns: DataTableColumn<Transaction>[] = [
{ id: 'date', header: 'Date', cell: (row) => row.dateLabel, allowsSorting: true, width: '7rem' },
{ id: 'payee', header: 'Payee', cell: (row) => row.payee, isRowHeader: true },
{ id: 'category', header: 'Category', cell: (row) => row.category, hideBelow: 'md', listSlot: 'meta' },
{ id: 'amount', header: 'Amount', cell: (row) => row.amountLabel, align: 'end', total: '−$1,284.50', listSlot: 'end' },
];
export function Transactions({ rows, sort, openId }: TransactionsProps) {
return (
<DataTable
aria-label="Transactions"
items={rows}
columns={columns}
getKey={(row) => row.id}
getRowHref={(row) => `?open=${row.id}`}
currentKey={openId}
// Sorted by date, so each day's rows are adjacent: the list groups them under the day.
getGroup={sort.column === 'date' ? (row) => ({ id: row.date, label: row.dayLabel }) : undefined}
sortDescriptor={sort}
onSortChange={(next) => writeSortToUrl(next)}
emptyState={<EmptyState title="No transactions this month" />}
/>
);
}Props
The root also takes ref, and className is merged after its own class. A name is required: aria-label, or aria-labelledby with the id of a visible heading.
bulkActions readonly DataTableBulkAction[] | ReactNode | The bulk-action bar's actions, shown while any row is selected: data, which the bar lays out as up to three promoted buttons and a More menu that never wraps, or the app's own buttons as a node, drawn as they are. |
|---|---|
className string | Merged after the library's class on the root. |
columns readonly DataTableColumn<object>[] | The columns, in the order to draw them. |
currentKey Key | null | The row whose detail is open: its link is aria-current and the row draws the current bar. It is compared with each row's key by strict equality, so a string from the URL never matches a numeric key. |
disabledBehavior "all" | "selection" | What Default: 'all' |
disabledKeys readonly Key[] | Rows that cannot be selected, and with |
emptyState ReactNode | The empty body: an EmptyState, or any node. |
error ReactNode | The rows did not load. With no rows it is the body, in place of the empty state, such as an EmptyState with a
Retry action; with rows shown, a failed next page, it is a line under the table that a polite status says.
It wins over |
formatCount ((count: number) => string) | How the bar writes its count, such as in Persian digits. Default: String |
getGroup ((item: object) => { id: Key; label: string; }) | The list face's day groups: adjacent rows with the same |
getKey (item: object) => Key | Each row's key, stable across renders and unique: a later row with a key already used is not drawn.
|
getRowHref ((item: object) => string) | The row's detail, a URL the router opens. Rows without one do not open. |
isLoading boolean | Shows skeleton rows while there are no items, and marks the table busy. |
items readonly object[] | The rows, in the order to draw them. |
labels Partial<DataTableLabels> | The words the table writes itself, each with an English default. |
listBelow "32rem" | "40rem" | The width of the table's own box under which the list face is drawn in place of the table: a container
width, not a breakpoint. Default: '40rem' |
loadingRowCount number | Default: 8 |
onSelectionChange ((keys: Key[]) => void) | Called with the selected keys: always the given rows' keys, never React Aria's 'all'. |
onSortChange ((sort: { column: string; direction: 'ascending' | 'descending'; }) => void) | Called with the next sort when a sortable header is pressed: ascending, or descending when it was ascending. |
selectAllMatching ReactNode | After the bar's count: the app's "Select all 1,240 matching" button, or what it says once pressed. |
selectedKeys readonly Key[] | Turns selection on: a first column of checkboxes and the bulk-action bar. Always controlled, from the page's state and never the URL. Clear it when the rows change. |
sortDescriptor { column: string; direction: 'ascending' | 'descending'; } | Controlled from the URL: the column's id and its direction. |
align "end" | "start" | 'end' sets figures: tabular, unbroken, at the line's end in either direction. Default: 'start' |
|---|---|
allowsSorting boolean | The header is a button that sorts by this column: ascending, then descending. |
cell (item: object) => ReactNode | The cell for a row: a string or any node. |
firstSortDirection "ascending" | "descending" | The direction of the first press on this column's header, when another column is sorted: descending for a column read newest or largest first. Each later press flips it, two states only. Default: 'ascending' |
header string | The header's text, and its name. |
hideBelow "md" | The column leaves the table face when DataTable's own box is narrower than 48rem: a container width, not a
breakpoint. Under 40rem, or the table's |
id string | Unique; the sort descriptor's |
isHeaderHidden boolean | The header draws nothing and keeps |
isRowHeader boolean | Exactly one column is the row header. Default: the first column |
listSlot "end" | "hidden" | "meta" | "title" | Where the cell goes in an item of the list face, the face drawn under 40rem of the table's own box (or its
Default: 'title' for the row header, 'hidden' for every other column |
overflow "truncate" | "wrap" | How a cell's text that is too long for its column reads: on one line ending in an ellipsis, or wrapped to two lines and clamped there, for a memo a reader wants to read without opening the row. Figures never wrap. Default: 'truncate' |
textValue ((item: object) => string) | The row's name when the row header's cell is not a string. |
total ReactNode | The totals row's cell for this column. |
width string | A CSS width for the column, written on its header cell: '8rem', '20%'. Default: shares what is left |
icon ReactNode | A 16px Lucide icon, drawn before the label on a button and in the menu, hidden from assistive technology. |
|---|---|
id string | Unique among the actions. |
isDisabled boolean | |
isPromoted boolean | A button in the bar while it has room; the rest are in More. At most three are drawn as buttons; a fourth warns and goes to More. Under 40rem of the bar every action is in More. Default: false |
label string | The action's name, on its button and in the More menu. |
onAction (keys: Key[]) => void | Runs the action. Receives the selected keys present in |
clearSelection string | The bar's X that clears the selection: its name and its tip. Default: 'Clear selection' |
|---|---|
doneSelecting string | The same button while the checkboxes show, which hides them and keeps the selection. Default: 'Done' |
loading string | The status while loading. Default: 'Loading' |
moreActions string | The bar's More button, which opens the actions it does not show, and its menu's name. Default: 'More actions' |
select string | The first word of a row checkbox's name; React Aria adds the row's name after it, as "Select Corner Mart". Default: 'Select' |
selectAll string | The header checkbox's name. Default: 'Select all' |
selected (count: string) => string | The bar's count, given the count as Default: (count) => `${count} selected` |
selectItems string | The button above the list face that shows its checkboxes. Default: 'Select' |
toolbar string | The bulk-action bar's name. Default: 'Bulk actions' |
total string | The totals row's header cell. Default: 'Total' |
Accessibility
The table is a grid named by your aria-label or heading. Each header is a columnheader, with aria-sort on the sortable ones; the row header's cell is a rowheader, so a screen reader names each row by it. The current row's link is aria-current, and the row draws a bar and a bolder name, so the state is never color alone. React Aria announces a sort in its own words. While loading, the table is busy and a polite status outside it says "Loading" once; the skeletons are hidden from assistive technology. An error under the rows is a polite status too. A column whose header is hidden keeps its name. A row that focus reaches is scrolled clear of the sticky header and the bulk-action bar, so it is never hidden behind them. The table is one tab stop: no row link or checkbox is in the tab order, and the arrows reach them.
The list is a second grid of the same name, shown where the table is not, so a screen reader meets one; each day is a rowgroup named by its heading, and each item is named by its row's name and described by its second line and its end, so a screen reader hears the category and the amount too. With selection on, the list's Select button is a plain button whose name says what it does next, Select or Done, and focus stays on it when it is pressed; its items say whether they are selected whether their boxes show or not. On a touch screen Select, Done and Select all are 44px tall. A table or list with no rows takes focus itself and draws the ring. The list has no typeahead at all, so every letter reaches the page.
With selection on, the grid is multiselectable and each row says whether it is selected. The header checkbox is named "Select all" and a row's "Select" followed by the row's name, such as "Select Corner Mart". The bar is a toolbar named "Bulk actions": its X is named "Clear selection" and shows a tip of the same words on hover and keyboard focus, More is named "More actions" and opens a menu whose items keep the actions' names, and a control that leaves the bar hands focus to the one in its place. React Aria announces each selection change in its own English words, and the bar's polite status says the count in yours, once the selection has held for a moment, so a run of X presses is said once. Letters never move focus: J and K move as the arrows, X toggles, and every other letter does nothing, on any keyboard layout.
A field in a cell, such as a text input or a Date picker, keeps its own keys while it has focus, in the table and in an item of the list through its column's listSlot: its arrows move its caret or step its segments, and letters type. Escape returns focus to its row or item, where the arrows move between rows again, as the grid pattern's editing mode does; Tab moves on to the next field, row by row like a form.
| DownUpJK | Move to the next or previous row; from the first row, Up reaches the headers. J and K act as the arrows on any keyboard layout. |
|---|---|
| RightLeft | Into and between the cells of a row, skipping a column that has left, and back to the row; in the header row, between the headers, stopping at its ends. |
| HomeEnd | The first or last row; in a cell or a header, the row's first or last one that has not left. With Ctrl, from a cell, the table's first or last cell. |
| PageUpPageDown | Move by the rows that fit in the view, never past the first or the last row; with Shift, extend the selection. |
| Enter | Opens the focused row, from the row, one of its cells or its checkbox, and never selects it, in the list's Select mode too. On a sortable header, sorts. |
| Ctrl+EnterShift+Enter | Opens the focused row in a new tab or window, as a modifier click on its link does. A browser that ignores a modifier on a click a script sends, as Safari may, opens it in place: press Right to reach the row's link and the modifier Enter there. |
| SpaceX | Toggle the focused row's selection, and in the list the focused item's, whether its box shows or not. Space on a header sorts. |
| Shift+DownShift+Up | Extend the selection, past a column that has left too. |
| Mod+A | Selects every row on the page. |
| Escape | Clears the selection; in the bar too, with focus back in the table. On the bar's X, whose tip opens as focus reaches it, the first Escape closes the tip and the second clears. In a field in a cell or an item, returns focus to its row or item, where the arrows move between rows again; a field or an open calendar that uses Escape keeps it. |
| A-Z | Nothing: a letter never moves focus. A key no row or header starts with, such as a page shortcut, reaches the page; in the list every letter does. |
| Tab | Into and out of the table, one stop, then the bar, where Left and Right move between Clear selection, your select-all control, the buttons and More, and Enter, Space or Down on More opens its menu. Above the list, the Select button, and Select all while the boxes show, come first. From a field in a cell or an item, to the next field, row by row like a form, and out of the table after the last; Shift+Tab goes back. |
Design rules
What keeps data tables in the system.
Make the payee or the name the row header, the column a reader looks a row up by.
Put figures at the end, with their sign and currency in the string.
Add a totals row only for figures that add up.
Give the empty body an EmptyState that says what is missing and what to do.
Write the bar's count in your reader's words and digits.
Clear the selection when the page, the sort or a filter changes.
Name a total of one page "Page total".
Pass getGroup only while the rows are sorted by date.
Give each column a listSlot, so a phone reads the item as a line and its detail.
Write a negative amount with a real minus sign.
Offer Clear filters when filters hide every row.
Give a failed load an error with a way to retry.
Promote the one to three actions used most; put the rest in More.
Pass listBelow="32rem" beside a side panel when the columns that stay fit.
Never let the table scroll sideways. Let columns leave instead.
Never stripe the rows. A hairline between them is enough.
Never mark a row by color alone.
Never draw a spinner in place of the rows. Skeleton rows keep the shape.
Never keep the selection in the URL.
Never make a press on a row select it. A press opens; the checkbox selects.