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.
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.
Pricing sheet and the security overview attached; reply expected this week.
Discovery call with Clara
Walked through the rollout plan. Procurement joins the next session.
Note added
Budget at Cormorant Foods is approved for the next fiscal year; legal review is the last blocker.
Task completed — send the ROI model
Three-year model shared with the finance team.
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
FRFarid RahmanFarid Rahman
Lumen Studios
$9,800
LPLinh PhạmLinh Phạm
Quarry Motors
$14,500
QTQuang TrầnQuang Trần
Umbra Security
$16,500
UDUmut DemirUmut Demir
Qualified6 · $162.5K
Brightline Media
$22,500
BLBao LeBao Le
Granite Freight
$27,500
GMGrace MbekiGrace Mbeki
Kestrel Energy
$34,000
KDKarin DahlKarin Dahl
Orchid Hotels
$25,500
OGOliver GrantOliver Grant
Summit Textiles
$29,000
SWSam WhitakerSam Whitaker
Yellowstone Tools
$24,000
YTYuki TanakaYuki Tanaka
Proposal5 · $219.5K
Aurora Logistics
$48,000
ANAlice NguyenAlice Nguyen
Delta Shipworks
$31,000
DODaniel OseiDaniel Osei
Ironwood Bank
$58,000
IMIsabel MoreauIsabel Moreau
Riverbend Farms
$39,500
RIRosa IglesiasRosa Iglesias
Zenith Optics
$43,000
ZAZara AhmedZara Ahmed
Negotiation4 · $261K
Cormorant Foods
$76,000
CWClara WhitfieldClara Whitfield
Harbor Health
$64,000
HTHoàng Minh TuấnHoàng Minh Tuấn
Northwind Grain
$53,000
NHNadia HaddadNadia Haddad
Tidewater Marine
$68,000
TSTara SullivanTara 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.
Lead22 contacts · $946.8KTop of funnel
Qualified18 contacts · $891K82% carried forward
Proposal12 contacts · $728.5K67% carried forward
Negotiation7 contacts · $509K58% carried forward
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.
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
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.