Data Table
A Shopify admin-style index table. One search bar holds views, filter chips and GitHub-style qualifiers, alongside saved views, sortable and filterable headers, and cross-page selection with bulk actions.
Installation
npx shadcn@latest add @fujin/data-tableThis adds table, button, badge, checkbox, dropdown-menu, input,
popover, skeleton and tooltip, plus @tanstack/react-table and nuqs.
Usage
useDataTable holds the state and <DataTable> renders it.
import { DataTable } from "@/components/data-table/data-table"
import type { DataTableColumnDef, FilterDef } from "@/lib/data-table/types"
import { useDataTable } from "@/lib/data-table/use-data-table"const columns: DataTableColumnDef<Product>[] = [
{
id: "title",
accessorKey: "title",
header: "Product",
meta: { lockVisibility: true },
},
{ id: "status", accessorKey: "status", header: "Status" },
{
id: "price",
accessorKey: "price",
header: "Price",
meta: { align: "end" },
},
]
const filters: FilterDef<Product>[] = [
{
key: "status",
label: "Status",
type: "enum",
pinned: true,
negatable: true,
options: [
{ value: "active", label: "Active" },
{ value: "draft", label: "Draft" },
],
},
{ key: "price", label: "Price", type: "number-range", prefix: "$" },
]
export function Products({ products }: { products: Product[] }) {
const table = useDataTable({
data: products,
columns,
filters,
getRowId: (product) => product.id,
tableId: "products",
})
return (
<DataTable
table={table}
resourceName={{ singular: "product", plural: "products" }}
/>
)
}Define columns, filters and views outside the component, or memoize
them.
Filters
A filter is a FilterDef. It doesn't have to be a column: Shopify filters
products on "Tagged with" without showing tags. key is the URL parameter, the
typed-qualifier name and the key in list.filters.
type | Picker | Value |
|---|---|---|
enum | Checkboxes with search; is / is not | { op: "in" | "not_in", values } |
async | Same, options loaded as you type | { op: "in" | "not_in", values } |
text | "contains" input | { op: "contains", value } |
boolean | Yes / No | { op: "eq", value } |
number-range | Min and max | { op: "between", min, max } |
date-range | Today, Last 7/30/90 days, Last 12 months, custom | { op: "between", min, max } |
Options for every filter:
pinnedlists it first.negatableadds "is not".multiple: falseallows one value only.aliasesadds other qualifier spellings.getValue(row)tells client mode what to test. It defaults to the column whose id (ormeta.filterKey) matcheskey.
For values you only know from the data, uniqueOptions(rows, (row) => row.vendor) builds enum options with counts.
The search and filter bar
It works like the search bar in the Shopify admin's Products and Orders pages:
- Focus lists the filters. Pick one and its chip opens with the picker.
- Type to get matching filters and values. "dr" offers Status is Draft, and a view whose name matches the text is offered as well.
- Chips read "Status is Active, Draft". Click one to edit it; Delete or Backspace removes it. ← and → move between chips and back into the box.
- Free text searches after a short pause, or on Enter. In client mode
every word must appear somewhere in the row;
"quoted phrases"stay together. - ⓧ returns to the selected view's own query.
GitHub-style qualifiers work too. They turn into chips when you type a space or press Enter:
status:active,draft comma = any of
-vendor:acme is not (negatable filters)
title:"halo ring" quotes for spaces
price:10..20 price:>=10 ranges
created:2026-01-01..2026-03-31
giftCard:yesValues match an option's value or label, or a unique prefix of one
(vendor:acme finds "Acme Co"). After status:, the list offers that
filter's values. A qualifier the table doesn't recognise stays in the box
with a warning and never blocks the rest.
Views
const views: DataTableView[] = [
{ id: "all", label: "All", query: {} },
{ id: "active", label: "Active", query: { filters: { status: { op: "in", values: ["active"] } } } },
]
<DataTable table={table} views={views} />- The view menu sits at the start of the bar. A dot marks unsaved changes.
- Save as stores the current query as a new view. Names are unique and at most 40 characters.
- On a saved view, Save offers "Update view" or "Save as new view".
- Saved views can be renamed, duplicated and deleted. Built-in views can't.
With tableId set, saved views stay in this browser. Pass savedViews and
onSavedViewsChange to keep them on your server and share them with the
team.
Server mode and URL state
useUrlListState keeps search, sort, filters and page in the URL as flat,
readable parameters. The same parameters are what your API receives:
?q=ring&status=active,draft&vendor_not=Acme%20Co&price_gte=10&price_lte=50&created_gte=2026-01-01&sort=-price&page=2import { useUrlListState } from "@/lib/data-table/use-list-state"
const list = useUrlListState({ filters })
const { data, isFetching } = useQuery({
queryKey: ["products", list.params, list.pagination],
queryFn: () =>
api.products({ ...list.params, page: list.pagination.pageIndex + 1 }),
})
const table = useDataTable({
mode: "server",
data: data?.rows ?? [],
rowCount: data?.total ?? 0,
getRowId: (product) => product.id,
columns,
filters,
list,
pagination: list.pagination,
onPaginationChange: list.onPaginationChange,
})
<DataTable table={table} loading={!data} fetching={isFetching} />- Mount a
<NuqsAdapter>once near the app root:nuqs/adapters/next/appornuqs/adapters/react. - In Next.js, pages that fetch in a Server Component pass
useUrlListState({ filters, shallow: false }). - In server mode the types require
getRowId, so "Select all" never confuses rows from different pages, androwCount. - Server-mode columns are sortable only when they set
meta.sortField, because most APIs sort on a few fields only.
| Filter value | Parameters |
|---|---|
| any of | key=a,b (commas in values arrive as %2C) |
| none of | key_not=a,b |
| contains | key=text |
| yes / no | key=true / key=false |
| range | key_gte=min&key_lte=max (numbers, or YYYY-MM-DD) |
Selection and bulk actions
<DataTable
table={table}
bulkActions={[
{
id: "activate",
label: "Set as active",
promoted: true,
onAction: (selection) => activate(selection),
},
{
id: "archive",
label: "Archive",
supportsAllMatching: true,
onAction: (selection) => archive(selection),
},
{
id: "delete",
label: "Delete",
variant: "destructive",
onAction: (selection, rows) => remove(rows),
},
]}
rowActions={[{ id: "edit", label: "Edit", onAction: (row) => open(row) }]}
onRowClick={(row) => open(row)}
/>- Selecting rows swaps the header for the bulk-action bar. Shift-click selects a range.
- When every row on the page is selected, Select all N covers every matching row across pages. Rows unchecked after that are excluded, and Undo goes back.
- Promoted actions are buttons; the rest go under More actions.
onActionreceives aSelectionDescriptor:{ type: "include", ids }, or{ type: "exclude", ids, total }after "Select all". It also receives the selected rows that are loaded. Actions withoutsupportsAllMatchingare disabled after "Select all".- A row click opens the record. While rows are selected, it toggles the row instead, as in Shopify.
- Changing the search or filters clears the selection and returns to page 1. Sorting keeps the selection.
Columns and sorting
- Click a header to sort: ascending, then descending, then off.
- Each header menu offers sort, Filter (the same picker and state as the chips) and Hide column.
- The ⇅ panel holds "Sort by" and "Columns", where columns are shown, hidden and reordered with buttons. The leftmost column stays first.
- With
tableId, column choices persist in this browser. - The checkbox and first columns stick while a wide table scrolls sideways.
stickyOffsetkeeps the toolbar under a fixed site header.maxHeightscrolls rows under a sticky header row.
CSV export
import {
downloadCsv,
exportableRows,
rowsToCsv,
} from "@/lib/data-table/csv-export"
;<DataTable
table={table}
toolbarActions={
<Button
variant="outline"
size="sm"
onClick={() =>
downloadCsv(
rowsToCsv(table.core, exportableRows(table)),
"products.csv"
)
}
>
Export
</Button>
}
/>exportableRows returns the selection, or every matching row across pages,
in display order. Values that spreadsheets would run as formulas are
neutralized. In server mode, export on the server from list.params and the
selection descriptor.
Accessibility
- The search box is an ARIA combobox. Arrow keys move through suggestions, Enter picks one, and Escape closes the list, then clears the text.
- Chips, header menus, pickers and the sort and columns panel all work from the keyboard. Columns reorder with buttons, never by dragging only.
- A hidden status region announces the result count after a search or filter. The bulk-action bar announces the selection count.
- Headers set
aria-sort, checkboxes have 24px hit areas, and no single-key shortcuts are registered.
API Reference
Prop
Type