Data grid
The record table at Attio density: sticky headers, pinned columns, selection and resize.
Company | Owner | Stage | ICP fit | Invoices a month | AP spend | Last remittance | |||
|---|---|---|---|---|---|---|---|---|---|
PRPriya Raman | Customer | 91, strong fit | 4,210 | $18.4M | Sep 25 | ||||
TFTomás Ferreira | Negotiating | 84, strong fit | 2,960 | $11.2M | Sep 24 | ||||
JLJordan Lee | Evaluating | 72, good fit | 1,380 | $6.9M | Sep 18 | ||||
MOMaya Okafor | Customer | 88, strong fit | 3,540 | $14M | Sep 25 | ||||
WZWen Zhou | Evaluating | 77, good fit | 2,105 | $8.6M | Sep 21 | ||||
ABAisha Bello | Lead | 58, weak fit | 640 | $2.1M | Aug 29 |
import { ActionBar } from "@oration/canon/components/action-bar";import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { DataGrid, type DataGridColumn } from "@oration/canon/components/data-grid";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { Building2Icon, CalendarIcon, CircleDollarSignIcon, DownloadIcon, FileTextIcon, GaugeIcon, GlobeIcon, LayersIcon, PlusIcon, Trash2Icon, UserIcon,} from "lucide-react";import * as React from "react";export function Hero() { type Stage = "Customer" | "Negotiating" | "Evaluating" | "Lead"; type Supplier = { id: string; name: string; tint: TagColor; domain: string; owner: string; stage: Stage; icp: number; invoices: number; spend: number; remittance: string; }; const suppliers: Supplier[] = [ { id: "northwind", name: "Northwind Freight", tint: "blue", domain: "northwindfreight.com", owner: "Priya Raman", stage: "Customer", icp: 91, invoices: 4210, spend: 18_400_000, remittance: "Sep 25", }, { id: "halcyon", name: "Halcyon Health", tint: "teal", domain: "halcyonhealth.com", owner: "Tomás Ferreira", stage: "Negotiating", icp: 84, invoices: 2960, spend: 11_200_000, remittance: "Sep 24", }, { id: "orchard", name: "Orchard Street Market", tint: "green", domain: "orchardstreet.com", owner: "Jordan Lee", stage: "Evaluating", icp: 72, invoices: 1380, spend: 6_900_000, remittance: "Sep 18", }, { id: "meridian", name: "Meridian Parts Co.", tint: "amber", domain: "meridianparts.com", owner: "Maya Okafor", stage: "Customer", icp: 88, invoices: 3540, spend: 14_000_000, remittance: "Sep 25", }, { id: "cobalt", name: "Cobalt Ridge Foods", tint: "violet", domain: "cobaltridge.com", owner: "Wen Zhou", stage: "Evaluating", icp: 77, invoices: 2105, spend: 8_600_000, remittance: "Sep 21", }, { id: "bellweather", name: "Bellweather Logistics", tint: "pink", domain: "bellweather.io", owner: "Aisha Bello", stage: "Lead", icp: 58, invoices: 640, spend: 2_100_000, remittance: "Aug 29", }, ]; const stageColor: Record<Stage, TagColor> = { Customer: "green", Negotiating: "amber", Evaluating: "blue", Lead: "gray", }; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, }); const [selection, setSelection] = React.useState<Set<string>>( () => new Set(["halcyon", "orchard"]), ); const [showDomain, setShowDomain] = React.useState(false); const columns: DataGridColumn<Supplier>[] = [ { id: "name", header: "Company", icon: <Building2Icon />, width: 220, minWidth: 160, pinned: true, cell: (s) => ( <> <MonogramTile name={s.name} color={s.tint} size="sm" /> <button type="button" onClick={(event) => { event.stopPropagation(); toast.add({ title: `Opening ${s.name}` }); }} className="truncate rounded-sm font-medium text-foreground outline-none hover:underline focus-visible:underline" > {s.name} </button> </> ), }, ...(showDomain ? [ { id: "domain", header: "Domain", icon: <GlobeIcon />, width: 180, cell: (s: Supplier) => ( <span className="truncate text-muted-foreground"> {s.domain} </span> ), }, ] : []), { id: "owner", header: "Owner", icon: <UserIcon />, width: 160, cell: (s) => ( <> <Avatar className="size-5"> <AvatarFallback className="text-[9px] font-semibold"> {s.owner .split(" ") .map((part) => part[0]) .join("")} </AvatarFallback> </Avatar> <span className="truncate">{s.owner}</span> </> ), }, { id: "stage", header: "Stage", icon: <LayersIcon />, width: 130, cell: (s) => ( <Tag color={stageColor[s.stage]} dot> {s.stage} </Tag> ), }, { id: "icp", header: "ICP fit", icon: <GaugeIcon />, width: 110, cell: (s) => ( <span className="inline-flex items-center gap-2 tabular-nums"> <span aria-hidden="true" className="relative h-1.5 w-10 overflow-hidden rounded-full bg-muted" > <span className={cn( "absolute inset-y-0 left-0 rounded-full", s.icp >= 80 ? "bg-success" : s.icp >= 65 ? "bg-foreground/65" : "bg-foreground/25", )} style={{ width: `${s.icp}%` }} /> </span> {s.icp} <span className="sr-only"> ,{" "} {s.icp >= 80 ? "strong" : s.icp >= 65 ? "good" : "weak"}{" "} fit </span> </span> ), }, { id: "invoices", header: "Invoices a month", icon: <FileTextIcon />, width: 150, align: "right", cell: (s) => s.invoices.toLocaleString("en-US"), }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 120, align: "right", cell: (s) => usd.format(s.spend), }, { id: "remittance", header: "Last remittance", icon: <CalendarIcon />, width: 150, cell: (s) => ( <span className="text-muted-foreground">{s.remittance}</span> ), }, ]; const clear = () => setSelection(new Set()); const count = selection.size; const noun = count === 1 ? "company" : "companies"; return ( <div className="relative flex h-96 w-full flex-col overflow-hidden text-left"> <DataGrid label="Companies" className="flex-1" columns={columns} rows={suppliers} getRowId={(s) => s.id} selection={selection} onSelectionChange={setSelection} onRowClick={(s) => toast.add({ title: `Opening ${s.name}` })} trailingHeader={ <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="xs" className="text-muted-foreground" /> } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add column </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-48"> <DropdownMenuCheckboxItem checked={showDomain} onCheckedChange={(checked) => setShowDomain(Boolean(checked)) } > <GlobeIcon aria-hidden="true" /> Domain </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> } footer={ <div className="sticky bottom-0 left-0 z-10 flex h-9 items-center gap-6 border-t border-border bg-background px-3 text-xs text-muted-foreground tabular-nums"> <span>{suppliers.length} companies</span> <span> AP spend{" "} <span className="font-medium text-foreground"> {usd.format( suppliers.reduce( (sum, s) => sum + s.spend, 0, ), )} </span> </span> </div> } /> <ActionBar count={count} onClear={clear} className="bottom-12"> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="sm" /> } > <UserIcon data-icon="inline-start" aria-hidden="true" /> Assign owner </DropdownMenuTrigger> <DropdownMenuContent side="top" className="w-48"> {[ "Priya Raman", "Tomás Ferreira", "Jordan Lee", "Wen Zhou", ].map((name) => ( <DropdownMenuItem key={name} onClick={() => { toast.add({ type: "success", title: `Assigned ${count} ${noun} to ${name}`, }); clear(); }} > {name} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> <Button type="button" variant="ghost" size="sm" onClick={() => { toast.add({ title: `Exported ${count} ${noun}`, description: "companies.csv is in your downloads.", }); clear(); }} > <DownloadIcon data-icon="inline-start" aria-hidden="true" /> Export </Button> <Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => toast.add({ type: "error", title: `Delete ${count} ${noun}?`, description: "In the product this opens a confirmation.", }) } > <Trash2Icon data-icon="inline-start" aria-hidden="true" /> Delete </Button> </ActionBar> </div> );}Usage#
Data grid is the record table at Attio density: companies, people, deals, lists, tables, runs, tools and conversations all render through it. It draws 32px sticky headers, 36px rows ruled in hairlines, right-aligned tabular numbers, pinned columns, resizable widths and row selection, and leaves sorting, filtering and cell content to you. The common mistake is treating it as a styled <table> for a handful of rows inside a card; that's a Table or Card table. Data grid is for a full-bleed list page where the grid is the view and scrolls on its own.
When to use
- For the main list of a record type: Companies, People, Deals, a list or a table, full-bleed under the page toolbar.
- When rows can be selected for bulk actions, with the Action bar rising over the grid.
- When a row opens a record or a side panel and the open row needs to stay marked (
activeRowId). - When columns are wide or many, so the first column needs to stay pinned while the rest scroll sideways.
- For enrichment and AI columns whose cells resolve row by row.
When not to use
- For a short, static table inside a report card or panel, such as a leaderboard. Use Table
- For a list of keys, members or deliveries inside a settings section. Use Card table
- For pipeline stages you move cards between. Use Kanban
- For a short list the user reorders by hand, such as sequence steps. Use Sortable list
- For a list of rows with one line of text each and no columns to compare. Use Item
The Tabular Figures Rule
align: "right" does both for the whole column, header included.The Scroll Edge Rule
The Quiet Indigo Rule
The Label-Beside-Color Rule
Anatomy#
Company | Stage | AP spend | ||
|---|---|---|---|---|
Northwind Freight | Customer | $18.4M | ||
Halcyon Health | Negotiating | $11.2M | ||
Orchard Street | Evaluating | $6.9M |
- Header row. 32px, White Plane, 13px medium Slate Meta, sticky to the top of the grid's own scroll area.
- Column icon. Optional 14px icon in Faint Slate before the header label, set with the column's
icon. - Resize handle. An 8px hit strip on each header's right edge that shows a 1px indigo rule on hover and drags the width down to
minWidth. - Row checkbox. A 40px sticky column when
selectionis passed. Checkboxes appear on row hover, on keyboard focus, or for every row once any row is selected. - Pinned column. Columns with
pinnedstick left. The last one carries a hairline and, once the grid scrolls sideways, the pinned edge shadow. - Numeric column. With
align: "right", header and cells align right and cells use tabular figures.
Examples#
Density
Rows are 36px by default, 32px compact and 44px comfortable. The header stays 32px at every density.
Company | Stage | Invoices a month | AP spend | |
|---|---|---|---|---|
Northwind Freight | Customer | 4,210 | $18.4M | |
Halcyon Health | Negotiating | 2,960 | $11.2M | |
Orchard Street Market | Evaluating | 1,380 | $6.9M | |
Bellweather Logistics | Lead | 640 | $2.1M |
import { DataGrid } from "@oration/canon/components/data-grid";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Tag } from "@oration/canon/components/tag";import { Building2Icon, CircleDollarSignIcon, FileTextIcon, LayersIcon,} from "lucide-react";import * as React from "react";export function Density() { const [density, setDensity] = React.useState< "compact" | "default" | "comfortable" >("default"); const rows = [ { id: "northwind", name: "Northwind Freight", stage: "Customer", invoices: 4210, spend: "$18.4M", }, { id: "halcyon", name: "Halcyon Health", stage: "Negotiating", invoices: 2960, spend: "$11.2M", }, { id: "orchard", name: "Orchard Street Market", stage: "Evaluating", invoices: 1380, spend: "$6.9M", }, { id: "bellweather", name: "Bellweather Logistics", stage: "Lead", invoices: 640, spend: "$2.1M", }, ]; return ( <div className="flex w-full flex-col text-left"> <div className="flex h-11 items-center gap-3 border-b border-border px-3"> <SegmentedControl label="Row density" value={density} onValueChange={setDensity} options={[ { value: "compact", label: "Compact" }, { value: "default", label: "Default" }, { value: "comfortable", label: "Comfortable" }, ]} /> <span className="text-xs text-muted-foreground tabular-nums"> {density === "compact" ? 32 : density === "default" ? 36 : 44} px rows </span> </div> <DataGrid label="Companies" density={density} columns={[ { id: "name", header: "Company", icon: <Building2Icon />, width: 220, pinned: true, cell: (r) => ( <span className="truncate font-medium"> {r.name} </span> ), }, { id: "stage", header: "Stage", icon: <LayersIcon />, width: 140, cell: (r) => <Tag>{r.stage}</Tag>, }, { id: "invoices", header: "Invoices a month", icon: <FileTextIcon />, width: 150, align: "right", cell: (r) => r.invoices.toLocaleString("en-US"), }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 120, align: "right", cell: (r) => r.spend, }, ]} rows={rows} getRowId={(r) => r.id} /> </div> );}Pinned columns and resize
Scroll sideways: the pinned supplier column stays put and casts its edge shadow only once the grid has moved. Drag any header's right edge to resize it.
Supplier | Owner | Domain | Terms | Payment method | Invoices a month | AP spend | W-9 | |
|---|---|---|---|---|---|---|---|---|
Northwind Freight | Priya Raman | northwindfreight.com | Net 30 | ACH | 4,210 | $18.4M | On file | |
Halcyon Health | Tomás Ferreira | halcyonhealth.com | Net 45 | Virtual card | 2,960 | $11.2M | On file | |
Orchard Street Market | Jordan Lee | orchardstreet.com | Net 30 | ACH | 1,380 | $6.9M | Requested | |
Cobalt Ridge Foods | Wen Zhou | cobaltridge.com | Net 60 | Check | 2,105 | $8.6M | On file | |
Bellweather Logistics | Aisha Bello | bellweather.io | Net 30 | ACH | 640 | $2.1M | Missing |
import { DataGrid } from "@oration/canon/components/data-grid";import { StatusLabel } from "@oration/canon/components/status-dot";import { Building2Icon, CircleDollarSignIcon, FileTextIcon, GlobeIcon, UserIcon,} from "lucide-react";export function PinnedColumns() { const rows = [ { id: "northwind", name: "Northwind Freight", owner: "Priya Raman", domain: "northwindfreight.com", terms: "Net 30", method: "ACH", invoices: 4210, spend: "$18.4M", w9: "On file", }, { id: "halcyon", name: "Halcyon Health", owner: "Tomás Ferreira", domain: "halcyonhealth.com", terms: "Net 45", method: "Virtual card", invoices: 2960, spend: "$11.2M", w9: "On file", }, { id: "orchard", name: "Orchard Street Market", owner: "Jordan Lee", domain: "orchardstreet.com", terms: "Net 30", method: "ACH", invoices: 1380, spend: "$6.9M", w9: "Requested", }, { id: "cobalt", name: "Cobalt Ridge Foods", owner: "Wen Zhou", domain: "cobaltridge.com", terms: "Net 60", method: "Check", invoices: 2105, spend: "$8.6M", w9: "On file", }, { id: "bellweather", name: "Bellweather Logistics", owner: "Aisha Bello", domain: "bellweather.io", terms: "Net 30", method: "ACH", invoices: 640, spend: "$2.1M", w9: "Missing", }, ]; return ( <DataGrid label="Suppliers" className="h-64 w-full text-left" columns={[ { id: "name", header: "Supplier", icon: <Building2Icon />, width: 210, minWidth: 160, pinned: true, cell: (r) => ( <span className="truncate font-medium">{r.name}</span> ), }, { id: "owner", header: "Owner", icon: <UserIcon />, width: 150, cell: (r) => <span className="truncate">{r.owner}</span>, }, { id: "domain", header: "Domain", icon: <GlobeIcon />, width: 180, cell: (r) => ( <span className="truncate text-muted-foreground"> {r.domain} </span> ), }, { id: "terms", header: "Terms", width: 110, cell: (r) => r.terms, }, { id: "method", header: "Payment method", width: 150, cell: (r) => r.method, }, { id: "invoices", header: "Invoices a month", icon: <FileTextIcon />, width: 150, align: "right", cell: (r) => r.invoices.toLocaleString("en-US"), }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 120, align: "right", cell: (r) => r.spend, }, { id: "w9", header: "W-9", width: 130, cell: (r) => ( <StatusLabel tone={ r.w9 === "On file" ? "success" : r.w9 === "Requested" ? "warning" : "danger" } > {r.w9} </StatusLabel> ), }, ]} rows={rows} getRowId={(r) => r.id} /> );}Sorting through the header accessory
The grid renders rows in the order you pass them. Put a sort button in headerAccessory and sort the rows yourself; the button names the current order and the next one.
Company | AP spend | |
|---|---|---|
Northwind Freight | $18.4M | |
Meridian Parts Co. | $14M | |
Halcyon Health | $11.2M | |
Orchard Street Market | $6.9M | |
Bellweather Logistics | $2.1M |
import { Button } from "@oration/canon/components/button";import { DataGrid } from "@oration/canon/components/data-grid";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArrowDownIcon, ArrowUpIcon, Building2Icon, CircleDollarSignIcon,} from "lucide-react";import * as React from "react";export function SortingAccessory() { const [direction, setDirection] = React.useState<"desc" | "asc">("desc"); const suppliers = [ { id: "northwind", name: "Northwind Freight", spend: 18_400_000 }, { id: "halcyon", name: "Halcyon Health", spend: 11_200_000 }, { id: "orchard", name: "Orchard Street Market", spend: 6_900_000 }, { id: "meridian", name: "Meridian Parts Co.", spend: 14_000_000 }, { id: "bellweather", name: "Bellweather Logistics", spend: 2_100_000 }, ]; const rows = [...suppliers].sort((a, b) => direction === "desc" ? b.spend - a.spend : a.spend - b.spend, ); const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, }); const label = direction === "desc" ? "Sorted by AP spend, highest first. Sort lowest first" : "Sorted by AP spend, lowest first. Sort highest first"; return ( <DataGrid label="Companies by AP spend" className="w-full max-w-lg text-left" columns={[ { id: "name", header: "Company", icon: <Building2Icon />, width: 240, pinned: true, cell: (r) => ( <span className="truncate font-medium">{r.name}</span> ), }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 150, align: "right", headerAccessory: ( <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-xs" aria-label={label} onClick={() => setDirection((d) => d === "desc" ? "asc" : "desc", ) } className="text-foreground" /> } > {direction === "desc" ? ( <ArrowDownIcon aria-hidden="true" /> ) : ( <ArrowUpIcon aria-hidden="true" /> )} </TooltipTrigger> <TooltipContent> {direction === "desc" ? "Sort lowest first" : "Sort highest first"} </TooltipContent> </Tooltip> ), cell: (r) => usd.format(r.spend), }, ]} rows={rows} getRowId={(r) => r.id} /> );}Selection and the active row
Checking rows raises the Action bar with the count and bulk actions. Clicking a row marks it active with the same tint, as when a record opens in a side panel.
Invoice | Supplier | Due | Amount | ||
|---|---|---|---|---|---|
INV-20931 | Northwind Freight | Oct 2 | $18,240.00 | ||
INV-20874 | Halcyon Health | Oct 2 | $9,612.50 | ||
INV-20802 | Orchard Street Market | Oct 5 | $22,005.10 | ||
INV-20791 | Cobalt Ridge Foods | Oct 9 | $4,318.00 | ||
INV-20766 | Meridian Parts Co. | Oct 9 | $12,880.75 |
import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { DataGrid } from "@oration/canon/components/data-grid";import { toast } from "@oration/canon/components/toast";import { Building2Icon, CalendarIcon, CircleDollarSignIcon } from "lucide-react";import * as React from "react";export function SelectionAndActive() { const rows = [ { id: "INV-20931", supplier: "Northwind Freight", due: "Oct 2", amount: 18240, }, { id: "INV-20874", supplier: "Halcyon Health", due: "Oct 2", amount: 9612.5, }, { id: "INV-20802", supplier: "Orchard Street Market", due: "Oct 5", amount: 22005.1, }, { id: "INV-20791", supplier: "Cobalt Ridge Foods", due: "Oct 9", amount: 4318, }, { id: "INV-20766", supplier: "Meridian Parts Co.", due: "Oct 9", amount: 12880.75, }, ]; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }); const [selection, setSelection] = React.useState<Set<string>>( () => new Set(), ); const [active, setActive] = React.useState<string | null>("INV-20874"); const open = rows.find((row) => row.id === active); const total = rows .filter((row) => selection.has(row.id)) .reduce((sum, row) => sum + row.amount, 0); return ( <div className="relative flex h-80 w-full flex-col overflow-hidden text-left"> <div className="flex h-10 shrink-0 items-center border-b border-border px-3 text-13 text-muted-foreground"> {open ? ( <span> <span className="font-mono text-xs text-foreground"> {open.id} </span>{" "} is open in the side panel </span> ) : ( "Click a row to open it" )} </div> <DataGrid label="Invoices in the Friday payment run" className="flex-1" columns={[ { id: "id", header: "Invoice", width: 130, pinned: true, cell: (r) => ( <span className="font-mono text-xs">{r.id}</span> ), }, { id: "supplier", header: "Supplier", icon: <Building2Icon />, width: 210, cell: (r) => ( <span className="truncate">{r.supplier}</span> ), }, { id: "due", header: "Due", icon: <CalendarIcon />, width: 100, cell: (r) => r.due, }, { id: "amount", header: "Amount", icon: <CircleDollarSignIcon />, width: 140, align: "right", cell: (r) => usd.format(r.amount), }, ]} rows={rows} getRowId={(r) => r.id} selection={selection} onSelectionChange={setSelection} activeRowId={active} onRowClick={(r) => setActive(r.id)} /> <ActionBar count={selection.size} onClear={() => setSelection(new Set())} > <span className="px-1 text-13 text-muted-foreground tabular-nums"> {usd.format(total)} </span> <Button type="button" variant="ghost" size="sm" onClick={() => { toast.add({ title: `Held ${selection.size} ${selection.size === 1 ? "invoice" : "invoices"}`, description: "They move to next Friday's run.", }); setSelection(new Set()); }} > Hold from run </Button> </ActionBar> </div> );}Enrichment cells
A running column resolves row by row: queued cells shimmer as bars of varying width, running cells shimmer their label, then each lands as a value, No result or an error. Credits count down as values are found.
Supplier | AP contact | |
|---|---|---|
Northwind Freight | ||
Halcyon Health | ||
Orchard Street Market | ||
Bellweather Logistics | ||
Cobalt Ridge Foods |
import { Button } from "@oration/canon/components/button";import { DataGrid } from "@oration/canon/components/data-grid";import { cn } from "@oration/canon/lib/utils";import { Building2Icon, CircleAlertIcon, MailIcon, WandSparklesIcon } from "lucide-react";import * as React from "react";export function EnrichmentCells() { type Cell = | { status: "empty" } | { status: "queued" } | { status: "running" } | { status: "found"; value: string } | { status: "not-found" } | { status: "error"; message: string }; const suppliers = [ { id: "northwind", name: "Northwind Freight", outcome: { status: "found", value: "ap@northwindfreight.com" }, }, { id: "halcyon", name: "Halcyon Health", outcome: { status: "found", value: "payables@halcyonhealth.com" }, }, { id: "orchard", name: "Orchard Street Market", outcome: { status: "not-found" }, }, { id: "bellweather", name: "Bellweather Logistics", outcome: { status: "error", message: "Rate limited, retry in 1 min", }, }, { id: "cobalt", name: "Cobalt Ridge Foods", outcome: { status: "found", value: "invoices@cobaltridge.com" }, }, ] satisfies { id: string; name: string; outcome: Cell }[]; const queuedWidths = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-1/3"]; const [cells, setCells] = React.useState<Record<string, Cell>>(() => Object.fromEntries( suppliers.map((s): [string, Cell] => [s.id, { status: "empty" }]), ), ); const [credits, setCredits] = React.useState(1240); const timers = React.useRef<number[]>([]); React.useEffect(() => () => timers.current.forEach(clearTimeout), []); const run = () => { timers.current.forEach(clearTimeout); setCells( Object.fromEntries( suppliers.map((s): [string, Cell] => [ s.id, { status: "queued" }, ]), ), ); suppliers.forEach((s, index) => { const start = 400 + index * 650; timers.current.push( window.setTimeout( () => setCells((c) => ({ ...c, [s.id]: { status: "running" }, })), start, ), window.setTimeout(() => { setCells((c) => ({ ...c, [s.id]: s.outcome })); if (s.outcome.status === "found") setCredits((n) => n - 2); }, start + 900), ); }); }; const running = Object.values(cells).some( (c) => c.status === "queued" || c.status === "running", ); return ( <div className="flex w-full flex-col text-left"> <div className="flex h-11 items-center gap-3 border-b border-border px-3"> <Button type="button" variant="outline" size="sm" disabled={running} onClick={run} > <WandSparklesIcon data-icon="inline-start" aria-hidden="true" /> Find AP contact </Button> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {credits.toLocaleString("en-US")} credits left </span> </div> <DataGrid label="Suppliers with AP contact enrichment" columns={[ { id: "name", header: "Supplier", icon: <Building2Icon />, width: 220, pinned: true, cell: (s) => ( <span className="truncate font-medium"> {s.name} </span> ), }, { id: "contact", header: "AP contact", icon: <MailIcon />, width: 280, cell: (s, index) => { const cell: Cell = cells[s.id] ?? { status: "empty", }; if (cell.status === "empty") return ( <span aria-hidden="true" className="text-subtle-foreground" > – </span> ); if (cell.status === "queued") return ( <span aria-hidden="true" className={cn( "skeleton-shimmer h-2 rounded-full", queuedWidths[ index % queuedWidths.length ], )} /> ); if (cell.status === "running") return ( <span className="text-shimmer truncate"> Finding… </span> ); if (cell.status === "not-found") return ( <span className="text-muted-foreground"> No result </span> ); if (cell.status === "error") return ( <span className="flex min-w-0 items-center gap-1.5 text-destructive"> <CircleAlertIcon aria-hidden="true" className="size-3.5 shrink-0" /> <span className="truncate"> {cell.message} </span> </span> ); return ( <span className="truncate">{cell.value}</span> ); }, }, ]} rows={suppliers} getRowId={(s) => s.id} /> </div> );}Empty and loading
Loading is a skeleton at the same row height, rendered instead of the grid. When filters hide every row, headers stay and the empty state says which filter did it.
Company | Stage | AP spend |
|---|
No companies match these filters
import { Button } from "@oration/canon/components/button";import { DataGrid } from "@oration/canon/components/data-grid";import { EmptyState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonTable } from "@oration/canon/components/skeletons";import { Tag } from "@oration/canon/components/tag";import { Building2Icon, CircleDollarSignIcon, LayersIcon } from "lucide-react";import * as React from "react";export function EmptyAndLoading() { const [view, setView] = React.useState<"loading" | "empty" | "loaded">( "empty", ); const rows = [ { id: "halcyon", name: "Halcyon Health", stage: "Negotiating", spend: "$11.2M", }, { id: "northwind", name: "Northwind Freight", stage: "Customer", spend: "$18.4M", }, { id: "orchard", name: "Orchard Street Market", stage: "Evaluating", spend: "$6.9M", }, ]; return ( <div className="flex min-h-80 w-full flex-col text-left"> <div className="flex h-11 items-center border-b border-border px-3"> <SegmentedControl label="Grid state" value={view} onValueChange={setView} options={[ { value: "loading", label: "Loading" }, { value: "empty", label: "Filtered to nothing" }, { value: "loaded", label: "Loaded" }, ]} /> </div> {view === "loading" ? ( <SkeletonTable rows={5} columns={3} /> ) : ( <DataGrid label="Companies" columns={[ { id: "name", header: "Company", icon: <Building2Icon />, width: 240, pinned: true, cell: (r) => ( <span className="truncate font-medium"> {r.name} </span> ), }, { id: "stage", header: "Stage", icon: <LayersIcon />, width: 150, cell: (r) => <Tag>{r.stage}</Tag>, }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 130, align: "right", cell: (r) => r.spend, }, ]} rows={view === "empty" ? [] : rows} getRowId={(r) => r.id} empty={ <EmptyState size="sm" illustration="filters" title="No companies match these filters" description="Stage is Churned and AP spend is over $20M." action={ <Button type="button" variant="outline" size="sm" onClick={() => setView("loaded")} > Clear filters </Button> } /> } /> )} </div> );}States#
State | Company | AP spend | ||
|---|---|---|---|---|
Rest | Northwind Freight | $18.4M | ||
Row hover | Halcyon Health | $11.2M | ||
Selected | Orchard Street Market | $6.9M | ||
Active | Meridian Parts Co. | $14.0M |
import { DataGrid } from "@oration/canon/components/data-grid";import { Building2Icon, CircleDollarSignIcon } from "lucide-react";import * as React from "react";export function StatesMatrix() { const rows = [ { id: "rest", state: "Rest", supplier: "Northwind Freight", spend: "$18.4M", }, { id: "hover", state: "Row hover", supplier: "Halcyon Health", spend: "$11.2M", }, { id: "selected", state: "Selected", supplier: "Orchard Street Market", spend: "$6.9M", }, { id: "active", state: "Active", supplier: "Meridian Parts Co.", spend: "$14.0M", }, ]; const [selection, setSelection] = React.useState<Set<string>>( () => new Set(["selected"]), ); return ( <DataGrid label="Row states" className="w-full text-left" columns={[ { id: "state", header: "State", width: 140, pinned: true, cell: (r) => ( <span className="text-muted-foreground">{r.state}</span> ), }, { id: "supplier", header: "Company", icon: <Building2Icon />, width: 220, cell: (r) => ( <span className="truncate font-medium"> {r.supplier} </span> ), }, { id: "spend", header: "AP spend", icon: <CircleDollarSignIcon />, width: 120, align: "right", cell: (r) => r.spend, }, ]} rows={rows} getRowId={(r) => r.id} selection={selection} onSelectionChange={setSelection} activeRowId="active" rowClassName={(r) => r.id === "hover" ? "[&>td]:bg-surface" : undefined } /> );}| State | Treatment |
|---|---|
| Rest | White Plane cells with hairline right and bottom rules. Row checkboxes are hidden until a row is hovered or anything is selected. |
| Row hover | Every cell in the row fills Row Mist (--surface) over 100ms, and that row's checkbox fades in. |
| Selected | A 6% indigo tint mixed into the plane on every cell, including the pinned and checkbox cells. All checkboxes stay visible. |
| Active | The row whose id matches activeRowId, such as the record open in a side panel, takes the same 6% tint without a checked box. |
| Indeterminate | The header checkbox shows a dash when some but not all rows are selected; checking it selects every row passed in rows. |
| Scrolled sideways | data-scrolled-x is set on the grid and the last pinned column casts an 8px shadow over the columns sliding under it. |
| Resizing | The handle's 1px indigo rule shows on hover, and the whole page takes the column-resize cursor while dragging. |
| Empty | With no rows, headers stay in place and your empty node renders under them, pinned to the left edge. |
| Loading | Not drawn by the grid. Render SkeletonTable through Data state until rows arrive, at the same row height. |
Behavior#
- Widths start at each column's
width(180px if omitted) and are then owned by the grid. Dragging a handle sets a width no smaller thanminWidth(80px by default). Columns added later get their starting width; changingwidthon an existing column after mount has no effect. - The table is
table-fixedandw-max, with a filler column after the last one, so it always spans its container and grows past it when columns are wide. - The grid is its own scroll container. Give it a bounded height, as list pages do with
flex-1 min-h-0inside a full-height column, or the page scrolls instead and the header can't stick. - Pinned columns stick at their cumulative left offset, after the 40px checkbox column when selection is on. The edge shadow switches on as soon as
scrollLeftis above zero. - Selection is controlled: pass a
Setof row ids andonSelectionChange. The header checkbox selects or clears every row inrows, so pass the filtered rows, not the whole data set. onRowClickmakes the whole row clickable. Clicks on the checkbox cell don't reach it; stop propagation on links and menus inside cells yourself, as the companies grid does.densitysets cell height to 32, 36 or 44px. The header stays 32px.footerrenders inside the scroll area after the table. Make itsticky bottom-0 left-0so counts and totals stay in view.trailingHeaderadds a 160px column after the data columns, for an Add column control.- Only colors transition. Nothing in the grid moves, so there is nothing to reduce for reduced motion; enrichment shimmer stops under it.
Do and don't#
Company | AP spend | |
|---|---|---|
Northwind Freight | $18,412,000 | |
Halcyon Health | $1,120,480 | |
Orchard Street | $691,105 |
align: "right" so digits line up in tabular figures.Company | AP spend | |
|---|---|---|
Northwind Freight | $18,412,000 | |
Halcyon Health | $1,120,480 | |
Orchard Street | $691,105 |
Company | Industry | |
|---|---|---|
Northwind Freight | Freight | |
Halcyon Health | Researching | |
Orchard Street | No result |
Company | Industry | |
|---|---|---|
Northwind Freight | Freight | |
Halcyon Health | ||
Orchard Street | N/A |
Company | Stage | ||
|---|---|---|---|
Northwind Freight | Customer | ||
Halcyon Health | Negotiating | ||
Bellweather | Lead |
Company | Stage | |
|---|---|---|
Northwind Freight | Customer | |
Halcyon Health | Negotiating | |
Bellweather | Lead |
Content#
- Column headers are one or two nouns in sentence case: AP spend, Invoices a month, Last remittance. No units in brackets; put the unit in the cell.
- The first column names the record and links to it. Keep it pinned.
- Empty values get a word in Faint Slate that says why: None when nothing exists, Scoring or Researching while it's being worked out, No result when enrichment found nothing.
- Dates in rows are short and absolute (Sep 25); relative times (2h ago) are for activity columns only.
- Footer counts use the record noun: 6 companies, then totals such as AP spend $61.2M.
- Machine strings such as invoice and run IDs are Geist Mono at 12px; names, amounts and dates stay in Geist Sans.
Accessibility#
- The grid renders a real
<table>named bylabel(aria-label), withscope="col"headers andaria-rowcount, so screen readers announce rows and columns. - It isn't an ARIA grid: there is no arrow-key cell navigation. Put a real link in the first cell so each row has a keyboard path, as the companies and people grids do;
onRowClickalone is mouse-only. - Row checkboxes are hidden with opacity, not removed, so they stay in the tab order and fade in on
focus-visible. - Every row checkbox is labelled Select row, and the header one Select all rows. Screen readers can't tell rows apart from the checkbox alone; see known gaps.
- The resize handle is
aria-hiddenand pointer-only. Keep default widths readable so nobody has to resize to read a column. - Status cells pair the dot with text, and ICP bars carry a screen-reader band (ICP fit Strong), so nothing relies on color.
| Keys | Action |
|---|---|
| Tab | Moves through the header checkbox, row checkboxes, links and buttons in cells, in reading order. |
| Space | Toggles the focused checkbox. |
| Enter | Opens the focused link in a cell. |
| ShiftTab | Moves back through the same controls. |
Design tokens#
| Token | Used for |
|---|---|
--background | Header and cell fill (White Plane) |
--surface | Row hover fill (Row Mist) |
--primary | 6% tint on selected and active rows; the 1px resize rule; checked checkboxes |
--border | Hairline cell rules and the pinned hairline |
--muted-foreground | Header labels (Slate Meta) |
--subtle-foreground | Header icons (Faint Slate) |
8px 0 12px -8px oklch(0 0 0 / 0.18) | The pinned edge shadow, 0.6 in dark |
text-13 | Body Dense for headers and cells |
h-8 / h-9 / h-11 | Compact, default and comfortable rows |
API reference#
DataGrid
The grid. Generic over the row type T; columns and rows are plain data.
| Prop | Type | Default | Description |
|---|---|---|---|
columnsRequired | DataGridColumn<T>[] | No default | Columns in display order. See DataGridColumn. |
rowsRequired | T[] | No default | The rows to render, already filtered and sorted. Select all works on these. |
getRowIdRequired | (row: T) => string | No default | A stable id per row, used for keys and selection. |
labelRequired | string | No default | Accessible name of the table, such as Companies. |
selection | Set<string> | No default | Selected row ids. Pass with onSelectionChange to show the checkbox column. |
onSelectionChange | (selection: Set<string>) => void | No default | Called with a new set when a checkbox changes. |
onRowClick | (row: T) => void | No default | Makes rows clickable with a pointer cursor. Mouse only; keep a link in the first cell. |
activeRowId | string | null | No default | Marks one row with the selected tint, such as the record open in a side panel. |
density | "compact" | "default" | "comfortable" | "default" | Cell height: 32, 36 or 44px. |
trailingHeader | ReactNode | No default | Content for a 160px header cell after the last column, usually an Add column button. |
footer | ReactNode | No default | Rendered inside the scroll area after the table. Make it sticky to the bottom and left. |
empty | ReactNode | No default | Shown under the headers when rows is empty. |
rowClassName | (row: T) => string | undefined | No default | Classes for a row's <tr>. Cells paint their own background, so target them with [&>td]:. |
className | string | No default | Classes for the scroll container. Give it a bounded height (flex-1, h-96). |
DataGridColumn
The type of one column definition.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Unique per grid; keys the column and its width. |
headerRequired | ReactNode | No default | The header label. Truncates to one line. |
icon | ReactNode | No default | A lucide icon before the label, sized to 14px. |
width | number | 180 | Starting width in pixels. |
minWidth | number | 80 | Narrowest width the resize handle allows. |
align | "left" | "right" | "left" | right aligns header and cells right and sets tabular figures. |
pinned | boolean | false | Sticks the column to the left. Pin leading columns only, in order. |
headerAccessory | ReactNode | No default | Pushed to the right end of the header cell, for a sort or column menu button. |
cellRequired | (row: T, index: number) => ReactNode | No default | Renders a cell. Content sits in a flex row with a 6px gap; truncate long text. |
cellClassName | string | No default | Classes added to every <td> in the column. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Every row checkbox has the same name, Select row. There is no prop to name it after the record, so screen reader users hear a list of identical checkboxes.
onRowClick is attached to the <tr> with no keyboard equivalent, and the resize handle is pointer-only. Grids stay usable only because product columns put a link in the first cell.
Widths live in internal state. They can't be controlled, persisted per view or reset, and a changed width prop is ignored after the first render.
Loading isn't part of the grid. Every list page wraps it in Data state with SkeletonTable, and the enrichment cell states (queued, running, found, no result, error) are rebuilt by the tables grid rather than offered here.
The pinned edge shadow is a literal oklch(0 0 0 / 0.18) (0.6 in dark) rather than a token, unlike every other shadow in the system.
DESIGN.md's color section pairs the 6% selected tint with a 22% inset indigo ring; the grid draws only the tint, which matches its own Data grid section.