Patterns

CRM & Sales

The screens a revenue team lives in: a contact list that really sorts, filters and pages, the record behind a 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. Move a deal on the board and the funnel and the table row move with it; close one from a row menu 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 — a shared ./crm-domain module for the stage table, the owners and the formatters, and one ./en-labels module for the English strings.

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, and almost entirely package. PageHeader and PageContainer frame it, SearchFilterBar owns the query, DataTable owns sorting, selection, pinned columns and column visibility, and TablePagination owns the page maths. The app supplies the columns, the data and the wiring between them — that is all.

Everything here is live. Sort by any header, tick rows and reassign their owner, hide a column (turn Location on — it starts hidden), type in the search box and watch the count in the filter bar and the range in the pagination bar move together. Click a row: it becomes the record in the next section.

Contacts

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

Actions
Elena Vasquez[email protected]Everest AnalyticsClosed wonHuy Nguyen$92,000
Vera Lindqvist[email protected]Vantage AeroClosed wonLinh Vo$85,000
Clara Whitfield[email protected]Cormorant FoodsNegotiationLinh Vo$76,000
Marcus Reid[email protected]Meridian CareClosed wonHuy Nguyen$71,000
Tara Sullivan[email protected]Tidewater MarineNegotiationMai Tran$68,000
Hoàng Minh Tuấn[email protected]Harbor HealthNegotiationMai Tran$64,000
Isabel Moreau[email protected]Ironwood BankProposalHuy Nguyen$58,000
Nadia Haddad[email protected]Northwind GrainNegotiationLinh Vo$53,000
  • All state is controlled. The package holds no data, filters nothing and pages nothing, so the same table serves an in-memory list and a server-paged one without a second component. The cost is that the app owns filter → sort → slice.
  • Search runs through matchesSearch, the package's one matcher: a substring test with the tones stripped and the case folded on both sides, so tuan finds Hoàng Minh Tuấn. Every screen in the ecosystem answers a query the same way because they all call it.
  • Every filter change resets to page 1. Filtering down to six results while standing on page three shows an empty table, which reads as “no data”, not as “wrong page”.
  • Selection is keyed by id, not by row index, so it survives sorting, filtering and paging. The header checkbox only acts on the rows currently on screen.
  • A pinned column must declare width — the sticky offset is accumulated from it. Scroll the table sideways: Contact stays left, the action menu stays right.
  • Two empty states, not one. “Nothing here yet” and “nothing matches your filters” need different words and different buttons, so the table branches on the unfiltered row count to pick one. Search for something that matches nothing to see the filter state; select every row and delete them to see the other.

Contact record

What a row opens into. The header is LetterAvatar plus StatusPill, the quick actions are real mailto: and tel: links, and the activity feed is Item rows stamped with RelativeTime, filtered by FilterChips. Click a different row above and this panel follows.

Clara Whitfield

Negotiation

Chief Financial Officer · Cormorant Foods

Chicago, IL

EmailCall
Deal value
$76,000
Owner
Linh Vo
Last activity
Proposal recap sent to Clara Whitfield
Discovery call with Clara
Note added
Task completed — send the ROI model

RelativeTime speaks Vietnamese unless you hand it locale={enUS}, and every timestamp on this page also gets now={NOW} — a fixed clock, so the sample data never drifts and the server and the browser render the same string.

Deal pipeline

The package ships no board, so the columns are hand-written — but the cards are composed from real components, and the column order comes from sortByStatusPriority(DEAL_STAGES) rather than from a second list that can drift away from the table's Stage column. Move a card and the table row and the funnel move with it; push one all the way to closed and the metric row moves too.

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

The two arrow buttons on every card are not a fallback, they are the primary path: a board whose only way to move a deal is dragging cannot be operated from a keyboard, and cannot be tested without a pointer. Add drag on top of them, never instead of them.

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 fill uses the ink step of the brand colour (the raw gold fill is 1.86:1 on white), the track carries a border so 0% is still a visible control, and Radix supplies role="progressbar" with a real aria-valuenow.

  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 order and the sort comparator all read it.
  • Derive the metric row from the same list the table renders, so the two can never disagree.
  • Reset to page one whenever a filter changes, and clamp the current page when the result count drops.
  • Key selection by record id so it survives sorting, filtering and paging.
  • Give a bulk action bar a real undo path — or a confirmation, for the ones that have none.
  • Pin the identifying column left and the row menu right; a wide table is going to be scrolled.
  • Say “overdue” with a chip and a word as well as a colour.

Don't

  • Do not rebuild the table out of <table>: you lose sorting, pinning, column visibility, the skeleton state and the empty state in one go.
  • 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.
  • Do not sort a stage column alphabetically; sort it by pipeline order.
  • Do not tint a whole row with a translucent background when the table has pinned columns or stripes — the pinned cells inherit the alpha and go see-through.
  • Do not paint a rising bad number green: pass polarity.
  • Do not leave the packaged Vietnamese strings in an English product — including the ones only a screen reader hears.

Accessibility

  • Sortable headers are real buttons and carry aria-sort; the sort icon is never dimmed below the 3:1 threshold, because it is still an operable control.
  • Clickable rows join the tab order and answer Enter and Space, but keep role="row" — a role="button" row destroys the table semantics a screen reader needs.
  • Give the row action button an explicit label (“Actions for Clara Whitfield”), and stop its click from bubbling into the row handler.
  • Filter counts and the pagination range sit in role="status" regions, so filtering announces its own result.
  • Both board arrows name the deal and the direction; the disabled end of the pipeline is disabled, not silently inert.
  • Every Progress bar carries an aria-label naming the stage and the percentage.
  • Pass English labels to every table, filter bar and pagination bar: the packaged defaults include aria-label and sr-only strings that are invisible in review and audible to users.
  • One package string still has no prop to override it, and it is avoided here rather than shipped: the aria-label on the <nav> that PageHeader's breadcrumb renders. The × on a clearable Combobox does have one — clearLabel, on both Combobox and MultiCombobox; the multi-select one defaults clearable to true, so it carries the Vietnamese string with nothing switched on. Neither shows in a screenshot; both fail an audit. Check what a component emits, not only what it displays.
  • LetterAvatar always renders the name in sr-only text, so mark it aria-hidden when the name is already visible beside it.
  • Deal stage is never carried by colour alone — the pill always spells the stage out.

Related