CRM & Sales

The screens a revenue team lives in: a contact grid that really filters, sorts and edits in place, the record behind the focused row, the board the deal moves across, and the numbers all of it rolls up to.

Every demo on this page reads and writes one contact list. Drag a deal across the board and the funnel, the grid row and the record’s timeline move with it; close one from its Stage cell in the grid and the metrics move too. That is the point of the pattern, and the reason none of these previews are screenshots. The code samples split the same thing across the files a real app would have — one shared ./crm-domain module for the stage table, the owners, the formatters and the workspace’s region settings. There is no file of English strings any more: the package speaks English on its own, and one ComitorLocaleProvider at the root switches all of it.

Pipeline metrics

Four StatCards, every figure computed from the contact list below rather than typed in. tone paints the icon chip only; the colour of the change line comes from polarity, not from the arrow — which is why “stalled over 10 days” going up is red and not green.

Open pipeline

$698.8K

+12.5%vs. last quarter

19 deals still working

Closed won

$248K

+2 dealsthis quarter

3 deals signed

Win rate

43%

-3.2 ptsvs. last quarter

7 deals closed

Stalled deals

3

+2vs. last week

No activity logged in 10 days

Contact list

The centrepiece. A contact carries ten fields here, the questions a sales team asks of them stack up — open deals over $50,000 that nobody has touched in ten days — and most changes are made on the way past: a new owner, the next stage, one more tag. Paging through a read-only table and opening each record for that is slow, so the list is a DataGrid: conditions combine in the Filter and Sort menus, cells edit where they stand, and a column pastes in from a spreadsheet. PageHeader and PageContainer frame it. The stage chips are FilterChips that write the very filter the Filter menu lists — Stage has any of Proposal, Negotiation — with counts from TanStack’s faceting, which counts each stage under every filter except the stage one. The strip under the grid follows the focused row and draws what a cell cannot — faces, the stage’s colour, money, time — and so does the record in the next section. Ticking rows raises an ActionBar.

Everything here is live. Pick Proposal and Negotiation, then add “Deal value is greater than 50000” in the Filter menu; sort by Stage and it sorts by pipeline order; turn Location on from the View menu — it starts hidden; press Ctrl+F to find a cell. Move a deal to Closed won in its Stage cell and the metrics, the board and the funnel follow. Arrow down the grid and the strip keeps up, and so does the record below.

Contacts

Everyone in the pipeline, with the deal they are attached to.

Add row

26 of 26 contacts

Clara WhitfieldChief Financial Officer · Cormorant FoodsNegotiationLinh Vo$76,000Last activity EmailCall

  • Filters and sort are the app’s; the rest is the grid’s. The grid keeps focus, selection and editing to itself, but state hands it the filters and the sort, and onColumnFiltersChange and onSortingChange write them back — the chips have to reach them, and so could a URL or a saved view. The data is the page’s: onDataChange returns the whole list with a new object only for the rows an edit touched, and the page keeps it, logging any stage that moved — the same entry a drop on the board writes.
  • Search is the Filter menu and Ctrl+F. There is no search box. “Contains” in the Filter menu narrows the rows on any text column, with the package’s one matcher — tones stripped and case folded on both sides — so Contact contains tuan finds Hoàng Minh Tuấn (since 3.1.0; before that the grid kept accents). Ctrl+F finds and highlights cells without hiding any rows, by what a cell shows — an owner’s name, not their id — with the same folding.
  • No pages, and a selection keyed by id. The grid virtualises — only the rows in view are in the DOM — so the whole list scrolls in one box that height bounds, and there is no page to reset when a filter changes. getRowId keys the selection by contact, so a ticked row stays ticked through sorting and filtering, and the header checkbox ticks every row the filters leave.
  • Cells stay cells. Nothing in the grid is a pill, an avatar or a menu: a cell is one of nine kinds so that it can be edited, pasted and filtered. Stage and Owner are select cells; a select column sorts by its labels, so Owner orders by name out of the box, and Stage takes a sortingFn because its order is the pipeline’s, not the alphabet’s. Deal value is a number cell holding the bare number, so its header carries the unit: the workspace’s currency, not the language’s. Tags is a multi-select that folds its overflow into “+2” on its own and offers every tag in use, so one typed in the record below can be picked here.
  • Last activity is a day, and locked. The activity log writes it, so meta.readOnly keeps typing, pasting and clearing off that one column while the rest of the row stays editable. A date cell holds yyyy-MM-dd, so an accessorFn turns the timestamp into the day it fell on in the viewer’s time zone — the one the record’s timeline prints in — and the sort still reads the whole timestamp. The four open stages plus Last activity before a day ten days back is the Stalled deals card, as a list.
  • The bulk bar is ActionBar, and closing it is clearing the selection. open follows table.getSelectedRowModel(), and onOpenChange(false) calls table.resetRowSelection() — the ×, Escape and every action that does not call preventDefault() all land in that one handler. Assign owner only opens a menu. Delete hands over to a ConfirmDialog outside the bar, because the bar unmounts the moment the selection clears — and it is the same dialog the grid’s own delete opens (Ctrl+Backspace, or the context menu): onRowsDelete returns a promise the dialog settles, and the grid keeps the rows ticked until it does. It settles with true once the rows are deleted and false when they are kept: since 3.3.3 the grid reads the answer, and settling with nothing reads as a delete.
  • The bar portals right after the grid, not to the end of the page. By default it lands at the bottom of <body>, so a keyboard user has to Tab through everything else to reach it. portalContainer points it at an empty <div> right after the grid: it still floats, and it is the next stop in the tab order, ahead of the strip’s Email and Call. anchor="container" centres it on that <div> — the grid’s width — rather than on a window that also holds the sidebar. The preview frame also sets contain: layout, which pins the fixed bar to the frame instead of your window and centres it there, so the preview leaves anchor at its default: measured in window coordinates, it would land off by the frame’s own offset. An app leaves out the contain and keeps the anchor.
  • New contact is a form, and the grid waits for it. A blank row is not a contact — it needs a name, a company and an owner — so onRowAdd opens the dialog instead of appending an empty line, and returns a promise: the new row’s position once the dialog creates it, null if it is cancelled. The New contact button, the grid’s footer row and Shift+Enter all take that one path. The page appends the record, the list drops its filters and sort so the last row is on screen, and the grid scrolls there and focuses the name. A pasted block taller than the list asks before it grows the list, then onRowsAdd appends that many unassigned leads for the paste to fill — one call, not one dialog per row.
  • New contacts store what the inputs report. In the New contact dialog, PhoneInput hands back E.164 (+14155550142) whatever was typed, and MaskInput hands back the bare number as its second argument. Both are given the workspace’s region explicitly — defaultCountry and currency — because neither is the language’s to decide. Without a country, a number typed without its + is not converted at all.
  • Two empty states, not one. “Nothing here yet” and “nothing matches your filters” need different words and different buttons, and the grid draws neither. So the list branches on the unfiltered count: with no contacts at all it shows the first in place of the grid, and when the filters leave none it shows the second under the header row. Filter for something that matches nothing to see the second; tick every row and delete them to see the first.

Contact record

What the focused row opens into. The header is LetterAvatar plus StatusPill, and the quick actions are real mailto: and tel: links. On the left, the record’s own data: TagsInput for the free-form labels the team types — the grid’s Tags column reads the same array, and offers any tag typed here — and KeyValue for custom fields. On the right, the history, as a Timeline filtered by FilterChips. Moves, closes and new records land on top of it as they happen: move this deal on the board below, then come back. Click another row in the grid above, or arrow to it, and this panel follows.

Clara Whitfield

Negotiation

Chief Financial Officer · Cormorant Foods

Chicago, IL

EmailCall

About

Deal value
$76,000
Owner
Linh Vo
Last activity
enterprise
procurement
multi-year
Custom fields

Activity

Proposal recap sent to Clara

Email ·

Pricing sheet and the security overview attached; reply expected this week.
Discovery call with Clara

Call ·

Walked through the rollout plan. Procurement joins the next session.
Demo for the Cormorant Foods team

Meeting ·

Six attendees. Follow-up questions on single sign-on and data residency.
Budget confirmed

Note ·

Budget at Cormorant Foods is approved for the next fiscal year; legal review is the last blocker.
Moved to Negotiation

Stage change ·

From Proposal.
Added as a lead

Stage change ·

Owner: Linh Vo.
  • No package string on this panel is told which language to speak. RelativeTime, the tags’ remove buttons (“Remove renewal”) and the custom fields’ (“Remove Seats”) all follow the nearest ComitorLocaleProvider — English here, because this site has none. The words the app wrote itself, like “Add field”, are the app’s to translate. The timeline’s own stamps do the same: TimelineTime formats nothing, it takes your words and a machine-readable dateTime, so the words come from Intl.DateTimeFormat with the locale that useComitorLocale() returns, in the viewer’s time zone — pinned, so the server and the browser print the same string. In an app that zone comes from the signed-in user’s profile: a time zone belongs to a person, not to a workspace.
  • No activeIndex on the timeline. That prop turns it into a progress track — completed, active, pending. An activity feed is a history, so every dot stays “pending” and takes the colour of its activity type instead, with the type also spelled out in words.
  • key={contact.id} on the custom fields. The record remounts the editor per contact, so one record’s duplicate-name error never shows up on the next. trim keeps its default: the field shows the space in “Contract term” as it is typed, and the record only ever stores the trimmed text.
  • Every RelativeTime on this page gets now={NOW} — a fixed clock, so the sample data never drifts and the server and the browser render the same string. The samples leave it out: a live record is measured against the real clock.

Deal pipeline

Kanban, with the stages as columns and the deals as cards. Drag a deal by its handle — with a mouse, with a finger, or from the keyboard: Space lifts it, the arrow keys carry it between stages, Space drops it and Escape puts it back where it was. The column counts and totals, the grid row, the funnel and the metric row all move while the card is still in the air, because Kanban writes every drag-over straight through onValueChange. The stage change itself is logged once, on the drop — open the record above to see it.

Lead4 · $55.8K

Foundry Labs

$15,000

Farid Rahman

Lumen Studios

$9,800

Linh Phạm

Quarry Motors

$14,500

Quang Trần

Umbra Security

$16,500

Umut Demir

Qualified6 · $162.5K

Brightline Media

$22,500

Bao Le

Granite Freight

$27,500

Grace Mbeki

Kestrel Energy

$34,000

Karin Dahl

Orchid Hotels

$25,500

Oliver Grant

Summit Textiles

$29,000

Sam Whitaker

Yellowstone Tools

$24,000

Yuki Tanaka

Proposal5 · $219.5K

Aurora Logistics

$48,000

Alice Nguyen

Delta Shipworks

$31,000

Daniel Osei

Ironwood Bank

$58,000

Isabel Moreau

Riverbend Farms

$39,500

Rosa Iglesias

Zenith Optics

$43,000

Zara Ahmed

Negotiation4 · $261K

Cormorant Foods

$76,000

Clara Whitfield

Harbor Health

$64,000

Hoàng Minh Tuấn

Northwind Grain

$53,000

Nadia Haddad

Tidewater Marine

$68,000

Tara Sullivan

Closed won3 · $248K

Everest Analytics

$92,000

Elena Vasquez

Meridian Care

$71,000

Marcus Reid

Vantage Aero

$85,000

Vera Lindqvist

Closed lost4 · $49.5K

Juniper Retail

$12,000

James Okafor

Pinnacle Legal

$18,000

Priya Raman

Westford Print

$11,000

Wesley Cho

Alder & Co.

$8,500

Adrian Alder

  • The columns are derived, never stored. Kanban wants Record<column, card[]>; the page has one flat contact list. The board builds the record with useMemo, and onValueChange writes the stage and the order back. The column order comes from sortByStatusPriority(DEAL_STAGES), so it cannot drift from the grid’s Stage column — and the closed stages are columns too, so winning a deal is one drop.
  • Drag by a handle, not by the whole card. dnd-kit starts a drag on mousedown and swallows the click that follows, so a card that is its own handle cannot also hold a working menu. The handle is a real button named after the deal (“Move Cormorant Foods”). KanbanItemHandle sets touch-none itself, so a finger on the handle lifts the card instead of scrolling the page.
  • Dragging is not the only way. A keyboard drag is still a drag, and WCAG 2.2 (2.5.7) asks for a way to do the same thing with a single tap. That is the card’s menu, whose Move to stage group reaches any column in one click — and in the grid above, a deal’s Stage cell does the same.
  • The announcements name the deal and the stage. Kanban’s built-in ones follow the provider, but they say “card” and read out the column’s machine key (“…in column negotiation”). Its labels are functions that receive the position and that key, so the app looks up the names — in a useMemo over the live data, because a card’s stage changes while it is being dragged. The price: these sentences are now the app’s, so a multilingual app translates them itself.
  • Escape really puts it back. Two upstream bugs are fixed in the package’s port: a keyboard drop used to announce itself without moving any data, and Escape used to leave the card wherever the drag had taken it. Here the pre-drag value comes back through the same onValueChange.

Sales funnel

Each bar counts the contacts currently at that stage or further along; closed-lost records leave the chart. Progress draws the bar, which is worth more than it looks: the track carries a border so 0% is still a visible control, and Radix supplies role="progressbar" with a real aria-valuenow. The fill is the brand’s ink step — in the default palette’s light mode that is the gold fill itself, 1.86:1 on white — so the counts written beside each bar, not the bar, carry the figures.

  1. Lead22 contacts · $946.8KTop of funnel
  2. Qualified18 contacts · $891K82% carried forward
  3. Proposal12 contacts · $728.5K67% carried forward
  4. Negotiation7 contacts · $509K58% carried forward
  5. Closed won3 contacts · $248K43% carried forward

Follow-ups

The work attached to the relationships: Item rows with a real Checkbox, priority as a dot-variant StatusPill so it does not fight the stage chips for attention, and a due date that turns text-destructive-ink once it is past. Tick one: it drops to the bottom instead of leaving a dead row at the top.

5 follow-ups still open

Chase the signed order form
Tara Sullivan · Tidewater Marine
High
Confirm the security review scope
Hoàng Minh Tuấn · Harbor Health
High
Send the revised proposal
Alice Nguyen · Aurora Logistics
Medium
Share the ROI model with finance
Clara Whitfield · Cormorant Foods
Medium
Book the Q4 renewal call
Marcus Reid · Meridian Care
Low
Log the kickoff notes
Vera Lindqvist · Vantage Aero
Medium

Best practices

Do

  • Declare stages once as a StatusConfig array and let the pill, the chips, the board’s columns and the sort comparator all read it.
  • Derive the metric row and the board’s columns from the same list the grid renders.
  • Hold the grid’s filters and sort in app state once anything outside it — chips, a URL, a saved view — has to read or write them, and count the chips with faceting, against every filter but their own.
  • Give the grid getRowId, so a selection is keyed by record id and survives sorting and filtering.
  • Give a bulk action bar a real undo path — or a confirmation, for the ones that have none — and ask with the same dialog when the grid deletes on its own.
  • Pin the checkbox and the name left — a grid this wide is going to be scrolled sideways — and lock what the app works out with meta.readOnly, the way Last activity is.
  • Set the language once, with one ComitorLocaleProvider at the root — and pass the region explicitly: defaultCountry on PhoneInput, currency on MaskInput, both from the workspace settings.
  • Store what the inputs report, not what was typed: E.164 from the phone field, the unmasked number from the amount.
  • Log a stage change once, on the drop — not once for every column the card crossed.
  • Say “overdue” with a chip and a word as well as a colour.

Don't

  • Do not hand-roll an editable table. DataGrid already has filters and sorts that combine, editing in the cell, the clipboard, pinning, column visibility, find and virtualisation.
  • Do not draw a pill, an avatar or a menu inside a grid cell. A cell is one of nine kinds so that it can be edited, pasted and filtered; the rich view goes beside the grid, as the strip under this one does.
  • Do not let “add row” append a record your domain calls invalid. Open the form, and let the grid wait on it.
  • Do not ship a primary button that goes nowhere — the screen someone copies is the screen they keep.
  • Do not let dragging be the only way to move a deal between stages; a keyboard drag is still a drag.
  • Do not make the whole card the drag handle when the card holds a menu or a link.
  • Do not sort a stage column alphabetically; sort it by pipeline order.
  • Do not paint a rising bad number green: pass polarity.
  • Do not reach for labels to translate. The provider switches every packaged string at once, screen-reader text included; labels is for wording only your domain knows — the unit in “3 contacts selected”, a deal’s name in a drag announcement.
  • Do not infer the country or the currency from the interface language.

Accessibility

  • The contact grid, named “Contacts”, is a role="grid" with aria-rowcount. Each column header opens a menu — sort, pin, hide — and carries aria-sort; a ticked row carries aria-selected.
  • In the grid, the arrow keys move between cells, Enter or typing starts an edit and Escape cancels it. Escape once more leaves the cell; from there Tab walks the row checkboxes — Space ticks one — and on out of the grid, while Tab from a focused cell moves to the next cell. The strip under the grid and the record below follow the focused row, so a whole contact is one arrow key away rather than behind a click.
  • The count under the grid (“12 of 26 contacts”) and the result of a bulk action (“3 contacts assigned to Mai Tran.”) sit in role="status" regions, so each change announces itself without moving focus — the bar has already gone by the time its action lands.
  • The bulk bar is a named toolbar (“Bulk actions”): arrow keys move between its actions, its × is “Clear selection”, and Escape clears — unless a menu or dialog above it has already used that Escape. It is portalled right after the grid, so Tab reaches it next.
  • Every deal card’s menu button is named for its deal (“Actions for Clara Whitfield”) — an icon-only button says nothing without one.
  • The board works from the keyboard: Tab to a card’s handle, Space or Enter to lift, arrow keys to move, Space to drop, Escape to cancel. Every step is spoken through dnd-kit’s live region, in the words the app gave labels, and after a drop focus returns to the card’s handle in its new column.
  • Each board column is a group named by its own header — the stage, the deal count and the total — so a screen reader moving into a column hears where it is.
  • PhoneInput is a group of two controls. FormField’s label reaches the number field (the spread goes on PhoneInputField), so the group gets its own aria-label; the country button names its value (“Country: United States (+1)”).
  • In TagsInput, Backspace in the empty box highlights the last tag and a second Backspace removes it; each tag’s remove button is named (“Remove renewal”). Moving between tags with ← and → is not announced — a known limit of the headless core.
  • Screen-reader text follows the provider like visible text does — the toolbar’s name, the remove buttons, the country button — so strings nobody sees in a review switch language with the ones everybody sees, from one ComitorLocaleProvider. The board’s drag announcements are the deliberate exception: they are the app’s own sentences, because only the app knows a deal’s name.
  • Every Progress bar carries an aria-label naming the stage and the percentage.
  • LetterAvatar always renders the name in sr-only text, so mark it aria-hidden when the name is already visible beside it.
  • Deal stage and activity type are never carried by colour alone — the pill and the timeline both spell them out.

Related

  • Application shell — the frame this screen sits inside: sidebar, workspace switcher, command palette.
  • Tasks & projects — the same list machinery pointed at work instead of relationships.
  • DataGrid and FilterChips — the grid with its Filter, Sort and View menus, and the chips that write its stage filter.
  • Kanban and ActionBar — the board and the bulk bar, with their keyboard models and every prop.
  • Timeline, TagsInput, KeyValue, PhoneInput and MaskInput — what the record and the New contact dialog are built from.
  • StatusPill — the StatusConfig contract the stage table is built on.
  • Set the language — the one provider that replaces every English label set this page used to carry.