Records Table
Attio's record grid — sticky columns, typed cells, sortable headers, row selection.
A faithful rebuild of Attio's record grid, measured from the live app: 36px rows, 1px borderSubtle grid lines on both axes, a sticky checkbox column + sticky entity (first) column (with a scroll shadow), field-type icons in the header, real favicons, colour-coded tags, and typed cells. Selection, sort, and filters are self-contained.
Preview
Everything here is live:
- Sort — the Sort menu (or a column header) sorts by any field; the toolbar shows "Sorted by …", the header shows the direction.
- Filter — the Filter menu is a field picker; choosing a field drops a toolbar chip that opens a condition editor (contains / is / is not / is empty / is not empty) with a value. Multiple chips AND together.
- Column menu — click a header to open it: sort ascending/descending, filter by this field, or hide the field. Drag the header's right edge to resize (tracks the cursor).
- Select — row checkboxes, or the header checkbox for select-all (mixed state when partial). A selection brings up the bulk-action bar — Add to list (pins ★), Send email, Run workflow, and More (Export CSV / Delete).
- Edit — double-click any text / number / URL / date cell to edit inline; Enter commits, Esc cancels.
- Add — the New button (or the footer row) appends a record; the + header adds a column.
- Summary footer — click any footer cell to pick its aggregation (Count all / Not empty / Sum / Average / Min / Max). Number columns default to Sum.
- Sticky columns — scroll horizontally; the checkbox and Company columns stay pinned and cast a soft edge shadow.
Installation
bun add crispimport "crisp/styles.css";
import { RecordsTable, type Column, type Row } from "crisp";Usage
import { RecordsTable } from "crisp";
import { Building2, Globe, Tag } from "lucide-react";
const columns = [
{ key: "company", label: "Company", type: "entity", icon: Building2 },
{ key: "domain", label: "Domain", type: "url", icon: Globe },
{ key: "categories", label: "Categories", type: "tags", icon: Tag },
];
const rows = [
{ id: "stripe", company: { name: "Stripe" }, domain: "stripe.com", categories: ["Payments"] },
];
export function Example() {
return <RecordsTable columns={columns} rows={rows} />;
}Column types
Each column declares a type that decides how its cell renders:
type | Cell value | Rendered as |
|---|---|---|
entity | { name, avatar? } | Favicon/logo (letter-tile fallback) + name, sticky first column |
text | string | Plain text (em-dash when empty) |
tags | string[] | Colour-coded pastel chips (a value keeps its colour) |
number | number | Right-aligned, toLocaleString(), tabular figures |
url | string (host) | Brand-colored link to https://… |
date | string | Muted text |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | Column[] | sample columns | Column definitions (key, label, type, icon, width). |
rows | Row[] | sample companies | Row data keyed by column key; each needs an id. |
selectedIds | string[] | — | Controlled selection. Omit to let the table manage it internally. |
onSelectionChange | (ids: string[]) => void | — | Fires whenever the selection changes (controlled or not). |
showNew | boolean | true | Show the toolbar's New button (hide it when the page supplies its own). |
onOpenRecord | (row: Row) => void | — | Enables a hover ↗ button on each entity cell; fires with the row to open a Record Panel. |
Notes
- Sticky columns. The checkbox column pins at
left: 0, the entity column atleft: 40px; both keep their background over scrolling cells and cast a soft right-edge shadow once scrolled. - Controlled selection. Pass
selectedIds+onSelectionChangeto own the selection (e.g. to drive a side panel); otherwise it's internal. Sort, filters, edits, resize, and added rows/columns are internal state. - Menus (sort / filter condition / column header / summary) render into the grid via a portal so they escape the scroller's clip and the sticky-cell stacking, and animate in.
- Still measured, not faked. Every control does real work — filters filter, Delete removes rows, New/+ append, Export downloads a CSV. Virtualization for very large datasets is the one remaining performance item.
See the whole thing composed into a page in the Companies page example.
Field-type cells
Beyond text / number / url / tags, columns can be currency, date, status, or reference — each with its own inline editor measured from Attio's cell editors. Click a Stage (status), Close date (date), or Owner (reference) cell to edit it; double-click Value (currency) to type an amount.
const columns = [
{ key: "deal", label: "Deal", type: "entity" },
{ key: "stage", label: "Stage", type: "status",
options: [
{ value: "won", label: "Won", color: "#22a565" },
{ value: "lost", label: "Lost", color: "#e5484d" },
] },
{ key: "value", label: "Value", type: "currency", symbol: "$" },
{ key: "close", label: "Close", type: "date" },
{ key: "owner", label: "Owner", type: "reference",
refOptions: [{ value: "moon", label: "Moon Moon" }] },
];- status — a ghost Select whose options carry a colour dot; the trigger shows the selected dot + label.
- date — a ghost DatePicker (calendar popover).
- reference — a searchable ghost Select over
refOptions, rendered back as an avatar chip. - currency — a right-aligned number formatted with
symbol, edited inline.