Quire UI
Data display6

Data table

A table described once, with filters, views, selection, row actions with Undo and a keyboard layer, in one toolbar row.

Invoices/40

1–10 of 40 invoices

Installation

npx shadcn@latest add @quireui-pro/data-table

Part of Pro. Add the Pro registry with your token once, as shown in Installation, and the CLI brings in the free parts it builds on as well.

Usage

Describe the columns once with typed helpers, then hand the table your rows. Search, sorting, paging, counts and totals come from the column definitions.

const columns = defineColumns<Invoice>((column) => [
  column.person("customer", { sub: (row) => row.id, searchable: true, sortable: true }),
  column.badge("status", { labels, tones, icons, sortable: true }),
  column.text("owner", { searchable: true }),
  column.money("amount", { sortable: true, summary: "sum" }),
  column.date("due", { relative: true, sortable: true }),
]);

<DataTable
  title="Invoices"
  noun="invoices"
  rows={invoices}
  rowKey="id"
  columns={columns}
  filters={[filter.multi("status"), filter.range("amount"), filter.date("due")]}
/>

The helpers are typed to your rows, so a misspelled field is a type error. Everything sits in one row above the table and the table never moves while you work it: filters change in place, selecting turns that row into the selection bar, and the pager only appears when there is more than one page.

Columns

HelperShowsSorts and searches by
column.textText, with an optional quieter second line (sub)The text
column.badgeA status as a Badge, with labels, tones and icons per valueThe order you list the values in, so "draft, open, overdue, paid" stays a sequence
column.moneyRight aligned, tabular, cents dimmedThe amount
column.numberRight aligned and tabular, with Intl optionsThe number
column.dateA short date; relative adds "in 3 days" or "4 days ago" underneathThe date
column.booleanA check or a dashYes or no
column.personAn avatar, a name and a second lineThe name
column.customYour own cellThe value you give it

Every helper also takes sortable, searchable, hideable, hidden, sticky and width. Money and numbers take summary (sum, average, count or range), shown in the footer over every matching row, not only the page. Money and dates are formatted in en-US unless you pass locale, so the server and the browser always agree.

Filters and views

Each filter is a pill in the toolbar. Unset, it is a dashed "+ Status"; set, it turns solid and shows its value, "Status Overdue, Open", with a cross to clear it. A click opens its editor right under it:

  • filter.multi: the column's values with how many rows each would give.
  • filter.range: at least and at most, applied as you type.
  • filter.date: the Date range picker's panel. Type "last 30 days", pick a preset or two days. Presets roll, so "Last 7 days" in a saved view still means the last 7 days tomorrow; in server mode the table sends the days they cover today.
  • filter.boolean: yes or no.

Past four filters the rest wait under "More". Views are saved ways of looking at the rows, picked from the menu by the title with a live count each; a view can bring its own filters and order:

views={[
  { label: "All" },
  { label: "Overdue", filter: { status: ["overdue"] }, sort: { column: "amount", direction: "descending" } },
]}

Search waits behind an icon until it is wanted or / is pressed. The order, rows per page and which columns show sit behind one display button; clicking a header sorts too. storageKey remembers the hidden columns and the page size.

Selection and actions

bulkActions turns on selection: checkboxes that appear on hover, Shift-click ranges, and neighbouring selected rows joining into one block. While rows are selected the toolbar row becomes the selection bar, with the count, the total of the money column, "Select all" across pages and the actions.

rowActions adds actions to every row: those marked inline show as icon buttons on hover, and all of them are in the row's menu. when hides an action where it does not apply.

An action's run can return a function that reverses it. The table then shows a toast with Undo, which is kinder than asking first for anything that can be put back. For what cannot, confirm asks with a second click.

const remove: DataTableAction<Invoice> = {
  label: "Delete",
  icon: <TrashIcon />,
  danger: true,
  done: (n) => `Deleted ${n} invoices`,
  run: (rows) => {
    const before = list;
    setList(withoutRows(rows));
    return () => setList(before);
  },
};

Peek

peek renders a quick look at a row in a panel over the table's edge, opened with Space or a click. It is not modal: J and K keep moving the cursor and the panel follows, so you can read through rows one after another. Esc closes it.

Narrow tables

Below 40rem of the table's own width, not the screen's, so a table in a sidebar compacts too, statuses keep only their icon and person columns drop the avatar. The word stays for screen readers and shows on hover. Turn it off per column with compact: false.

Invoices40

1–5 of 40 invoices

Loading and empty

While loading with no rows, skeleton rows in the shape of each column hold the space. A refetch with rows already there dims them, so nobody acts on stale data. With no rows at all the table says so (empty for your own message); when a search or filter finds nothing, it offers to clear them.

Server mode

Pass total and the table stops narrowing the rows itself. It shows the rows you pass as the current page and reports what to fetch through onQueryChange: the search, filters, order, page and view. Search waits for a pause in typing, so every key is not a request. Give multi filters their options, and views their counts with viewCounts, since the table no longer sees every row.

Invoices–
<DataTable
  rows={page.rows}
  total={page.total}
  loading={loading}
  onQueryChange={async (query) => {
    setLoading(true);
    setPage(await fetchInvoices(query));
    setLoading(false);
  }}
  …
/>

Accessibility

The table is a grid with a row cursor, so Tab reaches it once and the keys do the rest; the cursor shows only while you use the keyboard. Sortable headers announce their order, selected rows are marked as selected, and every checkbox and icon button has a name. Press ? for the keys a table answers to; it only lists what that table supports.

KeyAction
J / K, ↓ / ↑Move the cursor; past the end of a page it carries on to the next
EnterOpen the row
SpacePeek at the row
XSelect the row
Shift+J / Shift+KExtend the selection
⌘A / Ctrl+ASelect every matching row
.Open the row's actions
/Search
EscClose the peek, then clear the selection
?Show these keys

Motion

The peek slides in from the edge in 160ms and leaves faster; with the keyboard it switches instantly, like everything that answers a key. Checkboxes and row actions fade in on hover. Selecting, filtering and paging never move the table.

Data table or something else?

  • A few rows you render yourself, with no filtering or selection: the free Table.
  • A list with one line per item and no columns: a plain list.

API reference

DataTable

A table described once: columns, search, sorting, paging and summaries come from the column definitions. Keys move a row cursor (J and K, or the arrows) and Enter opens the row.

Prop

Type

Style it with [data-slot="data-table"].

On this page