Table
One table for every list of records. Pass rows and columns. Turn on the features you need: sorting, search and filter chips, group rows with totals, row edit, column layout, virtual rows, and row drag.
Plain
Sortable headings, right-aligned numbers, and rows that link with getRowHref.
| Customers | 8 | 1,284 | Oct 2, 2026 |
| Invoices | 11 | 9,407 | Oct 14, 2026 |
| Bulb plantings | 6 | 312 | Sep 21, 2026 |
| Suppliers | 5 | 1 | Jul 11, 2026 |
| Wholesale orders and delivery windows | 14 | 0 | Aug 30, 2026 |
Grid
variant="grid" with selection (Shift+click picks a range), pinned columns, a pinned actions column, and Row edit from onRowSave.
Filters and groups
Search, filter chips, and group rows with totals. Use the Group menu to change the grouping.
Column layout
Pinned columns stay next to the checkboxes. The Columns menu hides and pins columns. Drag headers to reorder and edges to resize. persistKey keeps the layout.
Virtual rows
300 rows, with only the visible ones drawn. Drag the handles to reorder.
| Reorder | Bed | Crop | Rows |
|---|---|---|---|
| Bed 1 | Tulip | 4 | |
| Bed 2 | Daffodil | 5 | |
| Bed 3 | Hyacinth | 6 | |
| Bed 4 | Crocus | 7 | |
| Bed 5 | Tulip | 8 | |
| Bed 6 | Daffodil | 9 | |
| Bed 7 | Hyacinth | 10 | |
| Bed 8 | Crocus | 11 | |
| Bed 9 | Tulip | 12 | |
| Bed 10 | Daffodil | 4 | |
| Bed 11 | Hyacinth | 5 | |
| Bed 12 | Crocus | 6 | |
| Bed 13 | Tulip | 7 | |
| Bed 14 | Daffodil | 8 | |
| Bed 15 | Hyacinth | 9 | |
| Bed 16 | Crocus | 10 | |
| Bed 17 | Tulip | 11 | |
| Bed 18 | Daffodil | 12 | |
| Bed 19 | Hyacinth | 4 | |
| Bed 20 | Crocus | 5 | |
| Bed 21 | Tulip | 6 | |
| Bed 22 | Daffodil | 7 | |
| Bed 23 | Hyacinth | 8 |
Stack on narrow screens
narrow="stack" turns rows into cards on small containers. Headers carry hints, and rows accept dropped files.
| Folder | Owner | Files |
|---|---|---|
| Mara | 3 | |
| Joost | 18 | |
| Ines | 7 |
Sub-rows
getSubRows nests rows under a toggle.
| Region | Boxes |
|---|---|
| North | 64 |
| Keukenhof Growers | 58 |
| Amsterdam Florals | 6 |
| South | 42 |
| Lisse Bulb Co. | 42 |
Caption and totals
A caption, and a footer total from column aggregates.
| Customer | Boxes | Total |
|---|---|---|
| Keukenhof Growers | 40 | €1,880 |
| Lisse Bulb Co. | 12 | €420 |
| Amsterdam Florals | 6 | €282 |
| Hillegom Seeds | 22 | €990 |
| Total | 80 | €3,572 |
URL state
useTableUrlState keeps sort, filters, search, grouping, and page in the URL, so a shared link opens the same view.
| Crop | Region | ||||
|---|---|---|---|---|---|
| Keukenhof Growers | Tulip | North | 40 | €1,880 | Oct 2, 2026 |
| Lisse Bulb Co. | Daffodil | South | 12 | €420 | Oct 4, 2026 |
| Amsterdam Florals | Tulip | North | 6 | €282 | Oct 5, 2026 |
| Total | 137 | €5,657 |
Server data
manual hands sorting, search, and paging to the server. Pass rowCount and read onStateChange.
| Keukenhof Growers | 40 | €1,880 |
| Lisse Bulb Co. | 12 | €420 |
| Amsterdam Florals | 6 | €282 |
Loading and empty
Skeleton rows while loading, then the empty message.
Usage
import { Table, type TableColumn } from "@tulipfarm/sprout/table";
const columns: TableColumn<Order>[] = [
{ key: "customer", header: "Customer", sort: true, filter: "text" },
{ key: "crop", header: "Crop", filter: "select", group: true, options: crops },
{ key: "boxes", header: "Boxes", align: "right", sort: true, aggregate: "sum", edit: "number" },
];
<Table
aria-label="Orders"
data={orders}
columns={columns}
getRowId={(row) => row.id}
searchable
selectable
onRowSave={(row, changes) => save(row.id, changes)}
/>variant is "plain" (default) or"grid". Grid tables sit in a bordered scroll region capped at 60dvh, with a pinned header. narrow is"scroll" (default) or "stack", which turns rows into cards when the table is narrower than 36rem.
Columns
Each column reads row[key]. cell changes what shows, but sort, filter, and edit still use the raw value.sort adds a sort button. filter is"text", "number", "select","date", or "boolean". edit is"text", "number", "select","date", or "checkbox". optionsfeeds select filters and editors. group lets people group by the column. aggregate ("sum","count", "min", "max","mean") shows totals on group rows and in the footer.hint adds an info tooltip. width,pin, and hidden set the starting layout.
Rows
getRowHref or onRowClick turns the first cell into a link or button that covers the row. actions adds a pinned actions cell. onRowSave adds Edit, Save, and Cancel; if it rejects, the row stays open and shows the error.selectable adds checkboxes and callsonSelectionChange with row ids. Shift+click a checkbox to set every row since the last one picked. getSubRowsnests rows. onRowReorder adds drag handles.onRowDrop accepts files dropped on a row.pageSize adds pages, and virtual draws only the rows in view. loading shows skeleton rows, andempty shows when no rows match.
State and URLs
The table keeps its own state. Pass state to control any part, and onStateChange to hear every change. Withmanual, the server sorts, filters, and pages; passrowCount. persistKey saves column widths, order, pinning, and visibility to localStorage.
import { useTableUrlState } from "@tulipfarm/sprout/table-nuqs";
const url = useTableUrlState({ prefix: "orders-" });
<Table aria-label="Orders" data={orders} columns={columns} getRowId={(row) => row.id} {...url} />useTableUrlState keeps sort, filters, search, grouping, and page in the URL with nuqs, so a shared link opens the same view. Installnuqs and add its adapter.
Accessibility
The table renders native table elements. Headings carryaria-sort. Checkboxes, editors, drag handles, and resize handles all work from the keyboard: drag handles use Space and the Arrow keys, and resize handles use the Arrow keys. Group rows usearia-expanded. Virtual rows set aria-rowcountand aria-rowindex. The stack layout keeps table roles and labels each value with its column name.
Theme tokens
Row dividers use --sprout-line; grid dividers and the frame use --sprout-input. Hover and grid header tints are--sprout-table-hover and --sprout-table-head-bg. Selected rows use --sprout-primary-subtle.