Skip to content

Data grid

The record table at Attio density: sticky headers, pinned columns, selection and resize.

Status
Stable
Category
Data display
Adoption
Not used yet
import { DataGrid } from "@oration/canon/components/data-grid";
packages/canon/src/components/data-grid.tsx
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
6 companiesAP spend $61.2M
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

Every number that changes or gets compared is set in tabular figures, and numeric columns align right. align: "right" does both for the whole column, header included.

The Scroll Edge Rule

Clipped content shows its edge. The last pinned column casts its edge shadow only once the grid scrolls sideways, so at rest the grid reads as one flat ledger.

The Quiet Indigo Rule

Indigo in a grid means selection: a 6% tint on selected and active rows, the checked checkbox and the 1px resize rule. Never color a row, cell or column header to show a value.

The Label-Beside-Color Rule

A status cell is a dot and a word, never a colored cell or a bare dot. A strong ICP fit can turn its bar green because the number and a screen-reader band travel with it.

Anatomy#

Company
Stage
AP spend
Northwind Freight
Customer
$18.4M
Halcyon Health
Negotiating
$11.2M
Orchard Street
Evaluating
$6.9M
  1. Header row. 32px, White Plane, 13px medium Slate Meta, sticky to the top of the grid's own scroll area.
  2. Column icon. Optional 14px icon in Faint Slate before the header label, set with the column's icon.
  3. 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.
  4. Row checkbox. A 40px sticky column when selection is passed. Checkboxes appear on row hover, on keyboard focus, or for every row once any row is selected.
  5. Pinned column. Columns with pinned stick left. The last one carries a hairline and, once the grid scrolls sideways, the pinned edge shadow.
  6. 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.

36px rows
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.

INV-20874 is open in the 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.

1,240 credits left
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

Stage is Churned and AP spend is over $20M.
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            }        />    );}
States
StateTreatment
RestWhite Plane cells with hairline right and bottom rules. Row checkboxes are hidden until a row is hovered or anything is selected.
Row hoverEvery cell in the row fills Row Mist (--surface) over 100ms, and that row's checkbox fades in.
SelectedA 6% indigo tint mixed into the plane on every cell, including the pinned and checkbox cells. All checkboxes stay visible.
ActiveThe row whose id matches activeRowId, such as the record open in a side panel, takes the same 6% tint without a checked box.
IndeterminateThe header checkbox shows a dash when some but not all rows are selected; checking it selects every row passed in rows.
Scrolled sidewaysdata-scrolled-x is set on the grid and the last pinned column casts an 8px shadow over the columns sliding under it.
ResizingThe handle's 1px indigo rule shows on hover, and the whole page takes the column-resize cursor while dragging.
EmptyWith no rows, headers stay in place and your empty node renders under them, pinned to the left edge.
LoadingNot 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 than minWidth (80px by default). Columns added later get their starting width; changing width on an existing column after mount has no effect.
  • The table is table-fixed and w-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-0 inside 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 scrollLeft is above zero.
  • Selection is controlled: pass a Set of row ids and onSelectionChange. The header checkbox selects or clears every row in rows, so pass the filtered rows, not the whole data set.
  • onRowClick makes 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.
  • density sets cell height to 32, 36 or 44px. The header stays 32px.
  • footer renders inside the scroll area after the table. Make it sticky bottom-0 left-0 so counts and totals stay in view.
  • trailingHeader adds 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
Do. Right-align amounts and counts with 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
Don't. Leave numbers left-aligned in proportional figures. Columns of money become hard to compare at a glance.
Company
Industry
Northwind Freight
Freight
Halcyon Health
Researching
Orchard Street
No result
Do. Say what an empty cell means in Faint Slate words: None, Scoring, No result.
Company
Industry
Northwind Freight
Freight
Halcyon Health
Orchard Street
N/A
Don't. Leave cells blank or write N/A and null. A blank reads as still loading, and machine words read as broken.
Company
Stage
Northwind Freight
Customer
Halcyon Health
Negotiating
Bellweather
Lead
Do. Keep rows plain: Row Mist on hover, a 6% indigo tint when selected, and color only inside tags and status labels.
Company
Stage
Northwind Freight
Customer
Halcyon Health
Negotiating
Bellweather
Lead
Don't. Tint whole rows by stage or stripe them. The grid stops reading as one ledger, and color starts to carry meaning without a label.

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 by label (aria-label), with scope="col" headers and aria-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; onRowClick alone 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-hidden and 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.
Keyboard interactions
KeysAction
TabMoves through the header checkbox, row checkboxes, links and buttons in cells, in reading order.
SpaceToggles the focused checkbox.
EnterOpens the focused link in a cell.
ShiftTabMoves back through the same controls.

Design tokens#

Design tokens
TokenUsed for
--backgroundHeader and cell fill (White Plane)
--surfaceRow hover fill (Row Mist)
--primary6% tint on selected and active rows; the 1px resize rule; checked checkboxes
--borderHairline cell rules and the pinned hairline
--muted-foregroundHeader labels (Slate Meta)
--subtle-foregroundHeader icons (Faint Slate)
8px 0 12px -8px oklch(0 0 0 / 0.18)The pinned edge shadow, 0.6 in dark
text-13Body Dense for headers and cells
h-8 / h-9 / h-11Compact, default and comfortable rows

API reference#

DataGrid

The grid. Generic over the row type T; columns and rows are plain data.

Props of DataGrid
PropTypeDefaultDescription
columnsRequiredDataGridColumn<T>[]No defaultColumns in display order. See DataGridColumn.
rowsRequiredT[]No defaultThe rows to render, already filtered and sorted. Select all works on these.
getRowIdRequired(row: T) => stringNo defaultA stable id per row, used for keys and selection.
labelRequiredstringNo defaultAccessible name of the table, such as Companies.
selectionSet<string>No defaultSelected row ids. Pass with onSelectionChange to show the checkbox column.
onSelectionChange(selection: Set<string>) => voidNo defaultCalled with a new set when a checkbox changes.
onRowClick(row: T) => voidNo defaultMakes rows clickable with a pointer cursor. Mouse only; keep a link in the first cell.
activeRowIdstring | nullNo defaultMarks 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.
trailingHeaderReactNodeNo defaultContent for a 160px header cell after the last column, usually an Add column button.
footerReactNodeNo defaultRendered inside the scroll area after the table. Make it sticky to the bottom and left.
emptyReactNodeNo defaultShown under the headers when rows is empty.
rowClassName(row: T) => string | undefinedNo defaultClasses for a row's <tr>. Cells paint their own background, so target them with [&>td]:.
classNamestringNo defaultClasses for the scroll container. Give it a bounded height (flex-1, h-96).

DataGridColumn

The type of one column definition.

Props of DataGridColumn
PropTypeDefaultDescription
idRequiredstringNo defaultUnique per grid; keys the column and its width.
headerRequiredReactNodeNo defaultThe header label. Truncates to one line.
iconReactNodeNo defaultA lucide icon before the label, sized to 14px.
widthnumber180Starting width in pixels.
minWidthnumber80Narrowest width the resize handle allows.
align"left" | "right""left"right aligns header and cells right and sets tabular figures.
pinnedbooleanfalseSticks the column to the left. Pin leading columns only, in order.
headerAccessoryReactNodeNo defaultPushed to the right end of the header cell, for a sort or column menu button.
cellRequired(row: T, index: number) => ReactNodeNo defaultRenders a cell. Content sits in a flex row with a 6px gap; truncate long text.
cellClassNamestringNo defaultClasses 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.