crispmeasured from Attio

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.
8 records
google.com
182,000Mountain View, CA
Sundar Pichaitwitter.com/google29,800,000linkedin.com/company/google
307,000,000,000
stripe.com
8,000San Francisco, CA
Patrick Collisontwitter.com/stripe1,200,000linkedin.com/company/stripe
16,000,000,000
figma.com
1,300San Francisco, CA
Dylan Fieldtwitter.com/figma540,000linkedin.com/company/figma
600,000,000
notion.so
800San Francisco, CA
Ivan Zhaotwitter.com/notionhq480,000linkedin.com/company/notionhq
400,000,000
linear.app
60Remote
Karri Saarinentwitter.com/linear120,000linkedin.com/company/linear
30,000,000
vercel.com
500San Francisco, CA
Guillermo Rauchtwitter.com/vercel310,000linkedin.com/company/vercel
100,000,000
openai.com
3,500San Francisco, CA
Sam Altmantwitter.com/openai4,200,000linkedin.com/company/openai
3,400,000,000
anthropic.com
1,000San Francisco, CA
Dario Amodeitwitter.com/anthropicai520,000linkedin.com/company/anthropic
1,000,000,000

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 styled danger (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

Highlights
Connection strength
No connection
Next calendar interaction
No interaction
Team
No team
Estimated ARR
$307B
Funding raised
No funding raised
Employee range
100K+
Activity
Domain set to google.com
2 hours ago
Tagged 9 categories
1 day ago
Emails0
No emails logged with Google yet.
Notes0
No notes yet.
Tasks0
No tasks yet.

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 values reveals 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. ⌘K command 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.

On this page