CRUD & Detail Patterns
How crisp's components compose into a full CRUD app — list, create, read, update, delete — and the detail-page layouts behind them.
crisp isn't a bag of widgets — every component is a piece of one coherent app (Attio's Companies view). This guide shows how those pieces snap together into the four things every data app does — create, read, update, delete — and the two detail-page layouts that hang off them. Each pattern below is already shipped and live in the Companies page example; this page names the pattern and says when to reach for it.
The CRUD surface at a glance
A record app is two nested layouts:
- The list page — a full-window App Shell: a collapsible Sidebar rail beside a main pane holding a top bar (workspace actions), a view bar (view switcher · view settings · import/export · New), a toolbar (Sort · Filter), and the Records Table.
- The detail surface — one record, opened either as a full Record Page or a lightweight Record Panel peek drawer.
Everything below is composed from that one screen.
Create
Two entry points, deliberately different in weight:
- Modal create (the primary path). The view bar's New button opens a Dialog — a titled, focus-trapped modal with one labelled field per column and a
Create record ⌘↵action. Use it when creation is a considered act with several fields, validation, or a "Create more" loop for bulk entry. It never loses the user's place in the list. - Inline row (the quick path). The table's bottom + New record row appends an editable blank row in place (a Notion-style quick-add). Use it when a record is cheap to make and the user is already scanning the list.
Building your own create modal is the same three steps every time: hold open state, render a Dialog with a dialog-field per attribute, and on submit build the record and prepend/append it to the list. Coerce each field by its column type (entity → { name }, tags → split, number → parse) so a created row reads back identically to an edited one.
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen} title="New company" width={720}>
<DialogBody>
{columns.map((col) => (
<div className="dialog-field" key={col.key}>
<label className="dialog-field-label">{col.label}</label>
<Input placeholder={`Set ${col.label}…`} />
</div>
))}
</DialogBody>
<DialogFooter>
<button className="dialog-submit" onClick={create}>
Create record <span className="dialog-submit-key">⌘↵</span>
</button>
</DialogFooter>
</Dialog>;Read (list & browse)
The Records Table is the read surface, and it does the reading work, not just the rendering:
- Typed columns — each header carries a field-type icon; each cell renders by type (entity avatar+name, colour-coded tags, right-aligned numbers, ink-coloured links, dates).
- Sort — a multi-criteria configurator, or click a column header → Sort ascending / descending.
- Filter — a searchable attribute picker drops a toolbar chip with a condition editor (
contains / is / is not / empty), combined as AND. - Aggregations — a summary footer per column (sum / average / count / not-empty).
- Sticky structure — a pinned checkbox column and pinned entity (first) column with a scroll shadow, so identity and selection stay visible while scrolling wide.
The UX rule: the list is the home base. Create, peek, and bulk actions all keep the user on the list rather than navigating away.
Update
crisp updates in place — editing never means a round-trip to a separate form:
- Inline cell edit — double-click a text / number / url / date cell to edit it; Enter commits, Escape cancels. This is the fastest path and the one users reach for most.
- Detail-page field edit — on the Record Page, the Record Details list is the same field grid, editable one field at a time, for when you're already looking at the whole record.
- Schema edits — the column header menu (Move left / right, Edit column label, Hide from view) treats the table shape itself as editable state.
Whichever path, apply the change optimistically to local state first; the cell re-renders instantly and the row reads back through the same type coercion as create.
Delete (and bulk actions)
Destructive and bulk actions share one pattern: select → floating bulk bar → confirm.
- Checking rows raises a bulk action bar showing the count (
3 Selected) with the common action inline (Add to list) and the rest — including the destructive Delete — tucked under a More Menu, where Delete is styleddanger(red). - Keeping Delete one layer down (in the menu, not a bare button) is the guardrail: it's reachable but never a mis-click away.
Detail pages: two layouts
The same record renders two ways; pick by how much focus the task needs.
Full record page — the deep-work layout
The Record Page is a two-column layout measured from Attio:
- Left column — identity (favicon/logo + name + inline edit + favourite), a row of quick actions (compose email, note, task, share), the Record Details field list (32px rows, 34px pitch,
View all valuesreveals hidden fields), and Lists membership. - Main column — a tab bar (Overview / Activity / Emails / Tasks / …), a Highlights stat grid (responsive, gap-as-gridlines), and the Activity feed.
Reach for the full page when the task is the record itself — reading everything, composing, working through tabs.
Peek panel — the stay-in-context layout
The Record Panel is the same record body in a right-side drawer that slides over the list (a soft left-edge shadow, Esc / × to close). It shares the Record Page's field metrics but keeps the list visible behind it. Reach for the peek when the user needs a glance — checking a value mid-scan — without losing their place. It's the lighter half of the same "identity → details → activity" anatomy.
The UX patterns underneath
The threads that make the above feel like one app, not a component zoo:
- Measured, two-tier surfaces. Every overlay (menu, select, command palette, dialog) shares one popover language: radius 12–16, a layered float shadow, 32–36px rows, and a theme-robust inset border so edges survive dark mode.
- Stay in place. Create (modal), peek (drawer), and bulk actions (floating bar) all overlay the list rather than navigating away — the user's scroll position and selection survive.
- Progressive disclosure. Details show the first fields and reveal the rest on demand; tags collapse to
+N; destructive actions live one menu-layer down. - Keyboard throughout.
⌘Kcommand palette,⌘↵to submit a dialog, ↑/↓ + Enter in every list, Esc to dismiss, typeahead in menus. - Optimistic, local-first state. Every mutation updates local state immediately; the components are controlled, so wiring them to a real API is swapping the state setter for a mutation.
- Empty and edge states are first-class. Empty tabs, no-results filters, and "not in any list" all render intentional copy rather than a blank void.