Table
A table is one frame, and every part of it is a cell stacked inside: view tabs, the filter row, the bulk bar, the grid and the pager. You compose the parts you need and keep the data. Wide tables scroll sideways inside their frame, never the page.
Loading the live example…
"use client"
import { useMemo, useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import {
Status,
Table,
TableBody,
TableBulkBar,
TableCell,
TableFrame,
TableHead,
TableHeaderCell,
TableIndexCell,
TablePagination,
TableRow,
TableScroll,
TableSearch,
TableSortHeader,
TableState,
TableToolbar,
TableViews,
compareValues,
nextSort,
sortOf,
type Sort,
} from "@/components/keyline/ui/table"
import type { StatusKind } from "@/components/keyline/ui/status"
type State = "out" | "loading" | "held" | "delivered"
type Order = { id: string; client: string; rep: string; cases: number; due: string; state: State }
const ORDERS: Order[] = [
{ id: "DM-4102", client: "Scranton Business Park", rep: "Dwight", cases: 40, due: "2026-11-12", state: "out" },
{ id: "DM-4107", client: "Lackawanna County", rep: "Dwight", cases: 120, due: "2026-11-12", state: "out" },
{ id: "DM-4111", client: "Vance Refrigeration", rep: "Phyllis", cases: 6, due: "2026-11-11", state: "held" },
{ id: "DM-4115", client: "Blue Cross of Pennsylvania", rep: "Jim", cases: 60, due: "2026-11-13", state: "loading" },
{ id: "DM-4118", client: "Harper Collins", rep: "Stanley", cases: 24, due: "2026-11-16", state: "loading" },
{ id: "DM-4120", client: "Scranton White Pages", rep: "Andy", cases: 10, due: "2026-11-09", state: "delivered" },
{ id: "DM-4123", client: "Mount Airy Casino", rep: "Jim", cases: 35, due: "2026-11-17", state: "loading" },
{ id: "DM-4126", client: "Lackawanna County", rep: "Dwight", cases: 80, due: "2026-11-10", state: "delivered" },
{ id: "DM-4129", client: "Penn Paper Supply", rep: "Phyllis", cases: 15, due: "2026-11-11", state: "held" },
{ id: "DM-4131", client: "Wilkes-Barre Library", rep: "Stanley", cases: 18, due: "2026-11-18", state: "out" },
{ id: "DM-4134", client: "Sabre", rep: "Andy", cases: 300, due: "2026-11-19", state: "loading" },
]
const SAY: Record<State, [StatusKind, string]> = {
loading: ["waiting", "Loading"],
out: ["normal", "Out for delivery"],
held: ["attention", "Held"],
delivered: ["done", "Delivered"],
}
type Key = "id" | "client" | "cases" | "due"
const PAGE = 5
const day = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
export function OpenOrders() {
const [orders, setOrders] = useState(ORDERS)
const [view, setView] = useState("all")
const [q, setQ] = useState("")
const [sort, setSort] = useState<Sort<Key>>({ key: "due", direction: "ascending" })
const [page, setPage] = useState(0)
const [picked, setPicked] = useState<Set<string>>(new Set())
const rows = useMemo(() => {
const term = q.trim().toLowerCase()
const hit = orders.filter(
(o) =>
(view === "all" || (view === "held" ? o.state === "held" : o.state === "delivered")) &&
(!term || o.client.toLowerCase().includes(term) || o.id.toLowerCase().includes(term) || o.rep.toLowerCase().includes(term)),
)
return sort ? hit.sort((a, b) => compareValues(a[sort.key], b[sort.key], sort.direction)) : hit
}, [orders, view, q, sort])
const shown = rows.slice(page * PAGE, page * PAGE + PAGE)
const onPage = shown.filter((o) => picked.has(o.id)).length
const reset = () => (setPage(0), setPicked(new Set()))
const pick = (id: string, on: boolean) =>
setPicked((p) => {
const n = new Set(p)
if (on) n.add(id)
else n.delete(id)
return n
})
const deliver = () => {
setOrders((all) => all.map((o) => (picked.has(o.id) ? { ...o, state: "delivered" } : o)))
setPicked(new Set())
}
const head = (key: Key, label: string, align?: "end", first?: "descending") => (
<TableSortHeader align={align} sort={sortOf(sort, key)} onSort={() => (setSort((s) => nextSort(s, key, { first })), setPage(0))}>
{label}
</TableSortHeader>
)
const count = (f: (o: Order) => boolean) => orders.filter(f).length
return (
<TableFrame>
<TableViews
label="Orders"
value={view}
onValueChange={(v) => (setView(v), reset())}
views={[
{ value: "all", label: "All", count: orders.length },
{ value: "held", label: "Held", count: count((o) => o.state === "held") },
{ value: "delivered", label: "Delivered", count: count((o) => o.state === "delivered") },
]}
/>
<TableToolbar aria-label="Filter orders">
<TableSearch label="Search orders" placeholder="Client, order or rep" value={q} onChange={(e) => (setQ(e.target.value), reset())} />
</TableToolbar>
<TableBulkBar count={picked.size} scope={picked.size ? `${picked.size} of ${rows.length} orders` : "Select orders to act on them."}>
<Button layout="cell" variant="primary" disabled={!picked.size} onClick={deliver}>
Mark delivered
</Button>
</TableBulkBar>
<TableScroll>
<Table aria-label="Orders">
<TableHead>
<tr>
<TableIndexCell
header
label="Select every order on this page"
checked={shown.length > 0 && onPage === shown.length}
indeterminate={onPage > 0 && onPage < shown.length}
onCheckedChange={(on) => shown.forEach((o) => pick(o.id, on))}
/>
{head("id", "Order")}
{head("client", "Client")}
{head("cases", "Cases", "end", "descending")}
{head("due", "Due")}
<TableHeaderCell>Status</TableHeaderCell>
</tr>
</TableHead>
<TableBody>
{shown.length ? (
shown.map((o, i) => {
const [kind, words] = SAY[o.state]
return (
<TableRow key={o.id} selected={picked.has(o.id)}>
<TableIndexCell checked={picked.has(o.id)} onCheckedChange={(on) => pick(o.id, on)} code={String(page * PAGE + i + 1).padStart(2, "0")} label={`Select ${o.id}`} />
<TableCell className="font-kl-mono text-[13px]">{o.id}</TableCell>
<TableCell>
{o.client} <span className="text-kl-ink-3">· {o.rep}</span>
</TableCell>
<TableCell align="end">{o.cases}</TableCell>
<TableCell>{day.format(new Date(o.due))}</TableCell>
<TableCell>
<Status kind={kind}>{words}</Status>
</TableCell>
</TableRow>
)
})
) : (
<TableState
kind="empty"
colSpan={6}
count={0}
title={q ? `No orders match “${q.trim()}”` : "No orders in this view"}
body={view === "all" ? "Search by client, order number or rep." : "Other views may have it."}
action={
<Button onClick={() => (setQ(""), setView("all"), reset())}>
{q ? "Clear search" : "Show all orders"}
</Button>
}
/>
)}
</TableBody>
</Table>
</TableScroll>
<TablePagination page={page} pageSize={PAGE} total={rows.length} onPageChange={setPage} noun="orders" />
</TableFrame>
)
}Installation
npx shadcn@latest add @keyline-ui/tableRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import {
Table, TableBody, TableCell, TableFrame, TableHead, TableHeaderCell,
TablePagination, TableRow, TableScroll, TableSortHeader, nextSort, sortOf,
} from "@/components/keyline/ui/table"<TableFrame>
<TableScroll>
<Table aria-label="Orders">
<TableHead>
<tr>
<TableSortHeader sort={sortOf(sort, "client")} onSort={() => setSort((s) => nextSort(s, "client"))}>
Client
</TableSortHeader>
<TableHeaderCell align="end">Cases</TableHeaderCell>
</tr>
</TableHead>
<TableBody>
{rows.map((o) => (
<TableRow key={o.id}>
<TableCell>{o.client}</TableCell>
<TableCell align="end">{o.cases}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
<TablePagination page={page} pageSize={5} total={rows.length} onPageChange={setPage} noun="orders" />
</TableFrame>Examples
Expandable rows
TableExpandCell toggles a TableDetail that unfolds from the row's bottom keyline; the row itself never moves. With an index cell, give it rowSpan=2 while open, using useDetailPresence so it stays merged while the detail folds back.
Loading the live example…
"use client"
import { Fragment, useState } from "react"
import { Table, TableBody, TableCell, TableDetail, TableExpandCell, TableFrame, TableHead, TableHeaderCell, TableRow, TableScroll } from "@/components/keyline/ui/table"
const ORDERS = [
{
id: "DM-4107",
client: "Lackawanna County",
total: "$9,610.00",
lines: [
["Copy paper, letter, 20 lb", "100 cases", "$7,400.00"],
["Cardstock, white, 110 lb", "20 cases", "$2,210.00"],
],
},
{
id: "DM-4115",
client: "Blue Cross of Pennsylvania",
total: "$4,980.00",
lines: [
["Copy paper, legal, 20 lb", "50 cases", "$4,150.00"],
["Envelopes, #10, security tint", "10 boxes", "$830.00"],
],
},
{
id: "DM-4120",
client: "Scranton White Pages",
total: "$740.00",
lines: [["Copy paper, letter, 20 lb", "10 cases", "$740.00"]],
},
]
export function OrderLines() {
const [open, setOpen] = useState<Set<string>>(new Set(["DM-4107"]))
const toggle = (id: string) =>
setOpen((o) => {
const n = new Set(o)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
return (
<TableFrame>
<TableScroll>
<Table aria-label="Orders with their lines">
<TableHead>
<tr>
<TableExpandCell header label="Show lines" />
<TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell>Client</TableHeaderCell>
<TableHeaderCell align="end">Total</TableHeaderCell>
</tr>
</TableHead>
<TableBody>
{ORDERS.map((o) => (
<Fragment key={o.id}>
<TableRow expanded={open.has(o.id)}>
<TableExpandCell expanded={open.has(o.id)} onToggle={() => toggle(o.id)} label={`Lines in ${o.id}`} />
<TableCell className="font-kl-mono text-[13px]">{o.id}</TableCell>
<TableCell>{o.client}</TableCell>
<TableCell align="end" className="tabular-nums">
{o.total}
</TableCell>
</TableRow>
<TableDetail open={open.has(o.id)} colSpan={4}>
<dl className="m-0 grid grid-cols-[1fr_auto_auto] gap-x-6 gap-y-1 px-3 py-3 text-[14px]">
{o.lines.map(([item, qty, amount]) => (
<div key={item} className="contents">
<dt>{item}</dt>
<dd className="m-0 text-kl-ink-2 tabular-nums">{qty}</dd>
<dd className="m-0 text-end tabular-nums">{amount}</dd>
</div>
))}
</dl>
</TableDetail>
</Fragment>
))}
</TableBody>
</Table>
</TableScroll>
</TableFrame>
)
}Loading and errors
TableLoadingRows holds the grid's shape while it loads, and total= blanks the pager so it shows no stale numbers. TableState kind="error" is announced and offers the way back; this one fails once, then Retry works.
Loading the live example…
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Table, TableBody, TableCell, TableFrame, TableHead, TableHeaderCell, TableLoadingRows, TablePagination, TableRow, TableScroll, TableState } from "@/components/keyline/ui/table"
const INVOICES = [
["INV-2207", "Scranton Business Park", "$1,284.00"],
["INV-2208", "Lackawanna County", "$9,610.00"],
["INV-2164", "Harper Collins", "$2,450.00"],
["INV-2211", "Blue Cross of Pennsylvania", "$4,980.00"],
]
type State = "loading" | "error" | "ready"
export function InvoiceTable() {
const [state, setState] = useState<State>("loading")
const failed = useRef(false)
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
const load = () => {
setState("loading")
// the first load fails, as Angela's accounting server does after lunch; the retry works
timer.current = setTimeout(() => {
setState(failed.current ? "ready" : "error")
failed.current = true
}, 1200)
}
useEffect(() => {
load()
return () => clearTimeout(timer.current)
}, [])
return (
<TableFrame>
<TableScroll>
<Table aria-label="Unpaid invoices" aria-busy={state === "loading" || undefined}>
<TableHead>
<tr>
<TableHeaderCell>Invoice</TableHeaderCell>
<TableHeaderCell>Client</TableHeaderCell>
<TableHeaderCell align="end">Amount</TableHeaderCell>
</tr>
</TableHead>
<TableBody>
{state === "loading" ? (
<TableLoadingRows rows={4} cols={3} />
) : state === "error" ? (
<TableState
kind="error"
colSpan={3}
count="!"
title="Invoices didn't load"
body="Accounting's server didn't answer. Nothing changed; try again."
action={<Button onClick={load}>Retry</Button>}
/>
) : (
INVOICES.map(([id, client, amount]) => (
<TableRow key={id}>
<TableCell className="font-kl-mono text-[13px]">{id}</TableCell>
<TableCell>{client}</TableCell>
<TableCell align="end" className="tabular-nums">
{amount}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
<TablePagination page={0} pageSize={10} total={state === "ready" ? INVOICES.length : null} onPageChange={() => {}} noun="invoices" />
</TableFrame>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
TableFrame | section | Default — | The one frame. Put the other parts inside, in order. |
TableViews | { views, value, onValueChange, label? } | Default — | View tabs with counts, built on Tabs. |
TableToolbar · TableSearch | group · search input | Default — | The filter row. TableSearch takes a label for screen readers. |
TableBulkBar | { count, scope?, children? } | Default — | How many are selected, what that covers, and the actions as cell buttons. |
TableIndexCell | { checked, onCheckedChange, label, code?, header?, indeterminate? } | Default — | The selection column. In the header, indeterminate for a partial page. |
TableSortHeader | { sort, onSort, align?, priority?, levels? } | Default — | The header cell is the button. nextSort and sortOf run the cycle: ascending, descending, none. For several columns, nextSortList with Shift. |
TableRow | { selected?, expanded? } | Default — | A row of the grid. |
TableCell · TableHeaderCell | { align?: "end" } | Default — | Figures end-aligned. |
TableState | { kind: "empty" | "error", count, title, body?, action?, colSpan } | Default — | An Empty spanning the grid. Errors are announced. |
TablePagination | { page, pageSize, total, onPageChange, noun? } | Default — | The range and previous / next. page starts at 0; total= while loading. |
For rows that leave and come back (a cancel and its Undo), useRowMotion slides the rest into place. Sorting, filtering and paging are always instant. The parts render native table elements, so put your own aria-label on Table.