Patterns
Commerce & Checkout
A commerce product is two applications wearing one brand. The storefront — grid, product page, cart, checkout, confirmation, order history — is presentation, and stays close to Tier 1. The back office behind it — orders and inventory — is a data application, and is almost entirely composites. Both halves are below, built the way they should be built.
Every demo on this page is live: sorting sorts, filters filter, selection selects, and the bulk actions in the orders console really do change the rows and the numbers above them.
Half one · Storefront
Product Grid
Image-led cards with a rating, a price and an add action that works without hover. This is the part of commerce that is genuinely just layout: a Badge for the promo tag, a Button for the action, your own photography. Adding an item swaps the button for a stepper and updates the cart readout.
New season
Living room
1 in cart · $215
Linen Throw Pillow
Oak Side Table
Halo Floor Lamp
Terra Ceramic Vase
Willow Two-Seater
Product Detail
Gallery on the left, buy box on the right. The variant pickers are a Combobox — searchable, with a description per option — and availability is a StatusPill reading the same stock table the warehouse screen uses. Pick Charcoal ash to see the sold-out state.
Aria Lounge Chair
$389
A sculptural lounge chair with a solid hardwood frame and a hand-woven seat. Built for slow mornings.
Free shipping over $200 · 30-day returns
Cart
Line items with steppers, a promo field that accepts SPRING10 and rejects everything else, and a free-shipping Progress bar. Empty the cart and the screen hands over to EmptyState — an empty cart is a real state with a way out, not a blank panel.
Your cart
3 linesAria Lounge Chair
Seating · $389 each
1 Aria Lounge Chair$389Linen Throw Pillow
Textiles · $42 each
2 Linen Throw Pillow$84Terra Ceramic Vase
Decor · $68 each
1 Terra Ceramic Vase$68
Checkout
Grouped fields beside a summary that never leaves the screen. FormSection and FormField carry the label, the hint and the error wiring, so a checkout is field definitions rather than a pile of bare <input>s with placeholder-only labels. Press Place order with fields empty to see the invalid state.
Contact and shipping
Where the order goes and who to email about it.
Delivery speed
Changing this updates the summary immediately.
Payment
Card details never leave the payment iframe.
Order Confirmation
The reassurance screen: what was bought, what it cost, when it lands. CopyButton puts the order number on the clipboard and RelativeTime dates it — with locale={enUS}, or the package prints Vietnamese into an English page.
Thanks, Alice — your order is confirmed
We emailed the receipt to [email protected] ·
Arrives Sep 4, 2026 – Sep 8, 2026
- Confirmed — current step
- Packing
- Shipped
- Delivered
Order History
The customer's own list — the same orders the back office sees, at a shopper's density. Item rows, StatusPill from the shared fulfilment table, and FilterChips with live counts. Filter down to Returned and then away again to see both states.
Order history
Account · Alice Whitfield3 items · Placed · Aug 30, 2026
1 item · Placed · Aug 22, 2026
2 items · Placed · Aug 9, 2026
4 items · Placed · Jul 28, 2026
1 item · Placed · Jul 14, 2026
Half two · Operations
Both screens below are page bodies: in a real product they sit inside the application frame — sidebar, app header, workspace switcher — documented on the App pattern page. What changes here is everything below the header.
Orders Console
The screen the merchandising team lives in. PageHeader, StatCard, SearchFilterBar, DataTable and TablePagination — sorting, selection, pinned columns, a column menu, skeleton loading and an empty state, none of it hand-rolled. Select a few rows and mark them shipped: the pills change and the stat cards above recalculate, because they read the same array.
Orders
Every order from the storefront, the till and the marketplace.
Revenue captured
$6,583
+8.4%vs. last week
Orders
14
+11this week
Average order value
$658
−$14vs. last week
Awaiting fulfilment
3
+2since yesterday
| Channel | Actions | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| #CMTR-29481 | Alice WhitfieldPortland, OR | Online store | 3 | $511 | Paid | Unfulfilled | |||
| #CMTR-29480 | Marcus ReyesAustin, TX | Online store | 1 | $249 | Paid | Packing | |||
| #CMTR-29479 | Priya RamanSeattle, WA | Marketplace | 5 | $1,684 | Payment due | Unfulfilled | |||
| #CMTR-29478 | Tomás HerreraMiami, FL | Online store | 2 | $431 | Paid | Shipped | |||
| #CMTR-29477 | Nguyen Thi MaiDa Nang, VN | Online store | 4 | $866 | Paid | Shipped |
Inventory
Stock per variant, per location. The location Combobox re-reads the same rows against another warehouse — switch to the flagship store and watch the low-stock chips move. Press Recount to watch the skeleton loading state the table ships with.
Inventory
Stock by variant, per location.
| Variant | Category | Committed | Status | ||||
|---|---|---|---|---|---|---|---|
| Aria Lounge ChairARI-LC-01-CHA | Charcoal ash / Wide | Seating | 2 | 2 | 0 | Out of stock | $389 |
| Halo Floor LampHAL-FL-03-BLK | Matte black | Lighting | 0 | 3 | 0 | Out of stock | $249 |
| Halo Floor LampHAL-FL-03-BRS | Brushed brass | Lighting | 11 | 9 | 2 | Low stock | $249 |
| Aria Lounge ChairARI-LC-01-WAL | Walnut / Standard | Seating | 7 | 4 | 3 | Low stock | $389 |
| Willow Two-SeaterWIL-2S-01-MOS | Moss bouclé | Seating | 5 | 2 | 3 | Low stock | $1,290 |
| Aria Lounge ChairARI-LC-01-NAT | Natural oak / Standard | Seating | 24 | 6 | 18 | In stock | $389 |
| Oak Side TableOAK-ST-02-NAT | Natural oak | Tables | 31 | 5 | 26 | In stock | $215 |
| Terra Ceramic VaseTER-CV-07-CLY | Clay / Tall | Decor | 64 | 7 | 57 | In stock | $68 |
| Linen Throw PillowLIN-TP-04-SND | Sand / 45cm | Textiles | 142 | 18 | 124 | In stock | $42 |
Available = on hand − committed. Switching location re-reads the same rows against another warehouse.
One Label Set Per App
Both consoles above pass the same three objects. The package ships Vietnamese strings — including aria-label and sr-only text — so an English app declares them once and hands them to every table, toolbar and pager. Skip this and you ship a half-translated screen: English headers over a Vietnamese pager.
One string on these screens takes no prop at all: the <nav> name of the breadcrumb that PageHeader renders. It is audible only to a screen reader, which is exactly why it survives a design review — so the orders console drops the breadcrumb and an English app that wants one composes Breadcrumb itself. The sr-only name of the × button on a clearable Combobox is not in that category — it takes clearLabel, and every clearable filter in the toolbars above passes one.
import type {
DataTableLabels,
SearchFilterBarLabels,
TablePaginationLabels,
} from '@comitor/ui'
// The package ships Vietnamese strings — aria-labels and sr-only text included.
// Declare the English set ONCE and hand it to every table on the screen.
// Partial<>: any key you leave out keeps its packaged default.
export const EN_TABLE: Partial<DataTableLabels> = {
selectAllRows: 'Select every row on this page',
deselectAllRows: 'Clear the selection',
selectRow: 'Select this row',
deselectRow: 'Deselect this row',
emptyTitle: 'Nothing here yet',
emptyDescription: 'No records match the current view.',
columnToggle: 'Columns',
}
export const EN_SEARCH: Partial<SearchFilterBarLabels> = {
searchPlaceholder: 'Search…',
unitLabel: 'records',
clearSearch: 'Clear search',
filterCount: (count) => `${count} ${count === 1 ? 'filter' : 'filters'}`,
clearFilters: 'Clear filters',
// Keys that interpolate a number are FUNCTIONS, not placeholder strings: word order
// differs between languages, and this one returns a node so the digits keep tabular-nums.
resultSummary: (resultCount, total, unitLabel) => (
<>
<strong className="font-medium text-foreground tabular-nums">{resultCount}</strong>
{total !== undefined ? ` of ${total}` : ''} {unitLabel}
</>
),
}
export const EN_PAGINATION: Partial<TablePaginationLabels> = {
region: 'Pagination',
unitLabel: 'records',
pageSizePrefix: 'Show',
pageSizeSelect: (unitLabel) => `${unitLabel} per page`,
range: (start, end, total, unitLabel) => `${start}–${end} of ${total} ${unitLabel}`,
pageStatus: (page, pageCount) => `Page ${page} of ${pageCount}`,
firstPage: 'First page',
previousPage: 'Previous page',
nextPage: 'Next page',
lastPage: 'Last page',
}Best Practices
Do
- Keep prices, ratings and the add action visible without hover.
- Show a running total and break out shipping and tax before the last step.
- Declare one status table per axis — fulfilment, payment, stock — and let
StatusPillread it. - Sort status columns by the priority in that table, never alphabetically.
- Give the toolbar filters and the table the same source array, so counts never disagree.
- Reset to page 1 whenever a filter changes.
- Confirm irreversible money actions — refunds, cancellations — with
ConfirmDialog. - Write both empty states: nothing yet, and nothing matching.
Don't
- Do not rebuild the orders table out of
<table>— you will re-implement sorting, selection and column visibility, worse. - Do not surprise shoppers with fees that appear only at the final step.
- Do not pick a status colour with a local switch statement.
- Do not tint a whole table row to signal state: pinned cells inherit the tint and go translucent, and striped rows out-specify it.
- Do not leave the packaged Vietnamese labels in an English console.
- Do not put more than one primary button on a cart or checkout view.
- Do not rely on hover to reveal add-to-cart on touch devices.
- Do not paginate a filtered list without resetting the page.
Accessibility
- Product images are content, not decoration — they need real
alttext naming the product. - Quantity steppers announce the new value: the count sits in an
aria-liveregion and each button names its product. - Star ratings carry a text equivalent — "Rated 4.8 out of 5 from 124 reviews" — because five icons say nothing.
- Variant pickers use a visible
<Label htmlFor>and noaria-label: an aria-label would replace the visible text rather than match it. - Gallery thumbnails expose their selected state with
aria-pressed. - Checkout fields get a real label through
FormField, never a placeholder standing in for one, and errors are wired witharia-describedby. - The confirmation heading stays a heading. Putting
role="status"on it trades a real heading for an announcement that never fires — an explicit role replaces the heading role, and a live region rendered with its text already in place has no change to announce. - Status is never colour alone: every pill carries its label, and the short form is only for columns too narrow for the full one.
- Row actions live in a real
<button>with an accessible name that includes the order number, and they stop click propagation so they do not also open the row. - Give every table an
aria-labeland every pager a region name when a screen holds more than one.