Patterns
Commerce & Checkout
A commerce product is two applications wearing one brand. The storefront — grid, product page, reviews, cart, checkout, confirmation, order history — is presentation, and stays close to Tier 1. The back office behind it — orders, inventory and the catalogue — 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, the checkout will not move past a step it has not validated, the bulk actions in the orders console really do change the rows, the numbers above them and each order’s history, and a count typed into the inventory grid moves its line’s availability.
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 — and a read-only Rating for the stars, half stars included, with a sentence beside it for screen readers. 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
Reviews
One Rating component, two different jobs. The average is data — 4.8 from 124 reviews — so its stars are drawn and hidden, and a sentence says the number. The form is a control: a real radiogroup, each star named through itemLabel (“4 stars — Great”) and driven by a tap, a click or the keyboard — Tab lands on the stars without rating the product, and the arrow keys choose as they move. Post a review and the average and the bars move with it.
Aria Lounge Chair
4.8out of 5
124 reviews
- 5 stars: 104 reviews
- 4 stars: 15 reviews
- 3 stars: 3 reviews
- 2 stars: 1 reviews
- 1 star: 1 reviews
- 5 out of 5 stars.Warmer than the photos
The oak has a lovely grain, and the woven seat has not sagged after a month of daily reading.
Hana Sato · Verified purchase
- 4 out of 5 stars.Comfortable, sits low
Great chair, but the 38 cm seat is lower than most — worth measuring if you are tall.
Diego Salazar · Verified purchase
- 5 out of 5 stars.Ordered a second one
Natural oak for the living room, walnut for the study. Both arrived well packed.
Nguyen Thi Mai · Verified purchase
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
Four steps beside a summary that never leaves the screen. Stepper draws the step list and runs onValidate before every move forward — Continue, a click on a later step, an arrow key in the list. The step being left has to be complete; if it is not, focus goes to the first field that says why, and steps nobody has reached stay disabled. FormSection and FormField carry the label, hint and error wiring, and the phone number is a PhoneInput given the store’s country: type (503) 555-0142 the way people say it and the value is +15035550142. Press Continue with the fields empty to see the invalid state.
Two things to know. The card fields are stand-ins for the payment provider’s hosted iframe: MaskInput ships creditCard and creditCardExpiry masks with a Luhn check, but a card number typed into your own page puts that page in PCI scope — the provider’s field formats and checks the number itself. And keep the steps written out in the JSX, as they are here: the Stepper reads them while it renders, so the server HTML already has the numbers and a working Continue — a step rendered by a component of your own leaves the whole list at 0, with Back and Continue disabled, until hydration.
Contact and address
Where the order goes and who to tell about it.
Only for the courier, if they cannot find the door.
Order Confirmation
The reassurance screen: what was bought, what it cost, when it lands. CopyButton puts the order number on the clipboard, RelativeTime dates it in the app’s language with no locale prop, and the four stages are a Timeline. Not a Stepper: nothing here navigates. The current stage carries aria-current="step", and only what has happened gets a <time> — the rest are written as estimates.
Thanks, Alice — your order is confirmed
We emailed the receipt to [email protected] ·
Arrives Sep 4, 2026 – Sep 8, 2026
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
The 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, and a working list rather than a short one: ten fields an order, and questions that combine them — paid and unfulfilled and placed this week, or marketplace orders over $500, oldest first. So under the PageHeader and the StatCard tiles sits a DataGrid, whose Filter and Sort menus take several conditions at once. The fulfilment FilterChips and the channel Combobox write the very filters that menu lists, and the chip counts come from TanStack’s faceting, which counts under every filter except the chips’ own. Finding one order or one customer is the menu’s “contains” on that column, and Ctrl+F finds a string anywhere in the grid. There is no pager — the grid virtualises, so a year of orders scrolls like a page of them — and its View menu hides columns. The grid is readOnly: an order changes through actions that leave a history, never by typing into a cell. Its checkboxes stay, though — tick a few rows and an ActionBar rises with the bulk actions. Mark them shipped and the rows, the tiles and each order’s history change together, because all three read the same state. The panel under the grid follows the focused row — arrow keys and clicks both move it — and draws what a cell cannot: the pills, the history and the order’s own actions.
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
14 of 14 orders
History
A refund asks first, through one ConfirmDialog for the bar and the panel’s menu alike. It sits outside both and holds the order numbers in state, because the panel’s Refund has no selection behind it. A click in the dialog is not a click outside the grid, so “Keep them” leaves the ticks and the bar where they were. The bar’s own contract is small: open follows the grid’s selection, onOpenChange(false) — the ✕, Escape, or an action that has run — clears it with table.resetRowSelection(), and portalContainer puts it right after the grid in tab order instead of at the end of the page, and anchor="container" centres it on the grid rather than the window. Since 3.1.0 the grid counts clicks and focus inside the bar as its own, so choosing an action no longer clears the ticks before the action can read them. The result — “Marked as shipped: 3 orders” — goes to a role="status" line, because by then the bar has gone.
Inventory
Stock per variant, per location — and the one list on this page that is edited where it stands, because that is what a stock count is. Another DataGrid, one line per variant per location. The location and category Combobox pickers and the stock-level FilterChips write conditions the Filter menu lists, so low stock in Seating is two clicks, and the Sort menu takes Stock and then Available for a worst-first review. On hand and Reorder point are the numbers a stock-taker fixes, so they edit in the cell: type over one, or paste a column straight from a spreadsheet with Ctrl+V. Everything else is locked with meta.readOnly. Product, variant, SKU, category, location and the committed units belong to the catalogue and the order system; Available and Stock are worked out again in onDataChange for every line an edit touches, so they move on the same row as the count. The card under the grid follows the focused line and shows the same variant at every location. Switch the location to the flagship store and the chip counts move; press Recount and the grid gives way to its skeleton.
Inventory
Stock by variant, per location.
27 of 27 linesAvailable = on hand − committed; a line is low at or below its reorder point.
- Main warehouse
- 20 available
- East fulfilment centre
- 8 available
- Flagship store
- 11 available
Product Editor
Adding a listing. Every field is a package component that hands back plain data: a price typed as money in MaskInput and stored as the number 36; tags from TagsInput as a string array; specifications from KeyValue as key/value pairs; and a photo framed in Cropper, which reports the crop rather than cutting it — drag, scroll, pinch with two fingers, or use the arrow keys and + and −. Save, and the product joins the catalogue — a short list people read, so a DataTable rather than a grid — where BadgeOverflow folds the tags that do not fit a cell into +N.
New product
Draft · not in the store yet
Details
What the product card and the search index read.
Typed as money, stored as a number.
Struck through beside the price.
Enter or a comma adds a tag; Backspace in the empty field removes the last. 5 of 8.
Specifications
The table on the product page.
Photo

Drag to frame it; scroll, pinch or press + and − to zoom. Sends the whole photo.
In the storefront
- Material
- Glazed stoneware
- Capacity
- 180 ml
- Care
- Dishwasher and microwave safe
| Product | Price | Tags |
|---|---|---|
| Aria Lounge ChairARI-LC-01 | $389 | SeatingSolid oakBestsellerLiving roomMade to orderand 99 more SeatingSolid oakBestseller |
| Linen Throw PillowLIN-TP-04 | $42 | TextilesLinenMachine washableand 99 more TextilesLinenMachine washable |
| Oak Side TableOAK-ST-02 | $215 | TablesSolid oakOn saleLiving roomand 99 more TablesSolid oakOn sale |
| Halo Floor LampHAL-FL-03 | $249 | LightingBrassNew seasonDimmableLiving roomBedroomand 99 more LightingBrassNew season |
| Terra Ceramic VaseTER-CV-07 | $68 | DecorCeramicHandmadeand 99 more DecorCeramicHandmade |
| Willow Two-SeaterWIL-2S-01 | $1,290 | SeatingBoucléOn saleTwo-seaterLiving roomand 99 more SeatingBoucléOn sale |
Language Once, Region Explicitly
Every string on these screens — the aria-label and sr-only text included — comes from the package in English, and Vietnamese is a built-in table. So the three label objects this page used to hand every table, toolbar and pager are gone: the language is chosen once, by one ComitorLocaleProvider at the root (Set the language). The label props that remain carry this store’s own wording — the bulk bar’s labels (“3 orders selected”), a clearLabel that names its filter, the review form’s star names — not translation.
import { ComitorLocaleProvider } from '@comitor/ui'
import { getUserLocale } from '@/lib/session'
// The admin's language comes from their profile: "en", "vi", "en-GB"… One provider at the root
// reaches every table, pager, dialog, toast and aria-label — including the screens outside the
// app frame (sign-in, error pages) and dialogs portalled to the end of <body>.
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getUserLocale()
return (
<html lang={locale}>
<body>
<ComitorLocaleProvider locale={locale}>{children}</ComitorLocaleProvider>
</body>
</html>
)
}What the provider never decides is the region. A store trades in a currency and ships from a country whatever language its admin reads, so both come from the store’s settings and are passed explicitly: currency on every MaskInput, defaultCountry on every PhoneInput. Neither is inferred any more. Without them a price is in dollars, and a phone field has no country at all.
// Where the store TRADES — read from its settings, never from the language its admin reads.
// A Vietnamese-speaking merchant can sell in dollars; an English-speaking one can ship from Hanoi.
export const STORE_REGION = { currency: 'USD', country: 'US', locale: 'en-US' } as const
export const money = new Intl.NumberFormat(STORE_REGION.locale, {
style: 'currency',
currency: STORE_REGION.currency,
})
// …and handed to every field that needs it, explicitly:
//
// <MaskInput mask="currency" currency={STORE_REGION.currency} />
// Leave it out and the field prices in USD, whatever the store sells in.
//
// <PhoneInput defaultCountry={STORE_REGION.country}>
// Leave it out and there is no country at all: a domestic number is read as if it began
// with a country code, and "(503) 555-0142" is stored as +5035550142 — El Salvador.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.
- Validate the step being left in the Stepper’s
onValidate, and send focus to the first field in error. - Pass the store’s currency to every
MaskInputand its country to everyPhoneInput— from the store’s settings. A grid has no currency cell, so a money column is a number that names the same currency in its label: Total (USD). - Store what the fields hand back — the unmasked price, the E.164 number — and format only for display.
- Declare one status table per axis — fulfilment, payment, stock — and let every surface read it:
StatusPill, the chips, a grid column’s options. - Sort status columns by the priority in that table — a
sortingFnon the grid column — never alphabetically. - Give a working list — many fields, filters that combine, several sort keys, counts to correct — a
DataGrid, and keepDataTablefor a short list people read, like the catalogue. - Let chips and pickers write the grid’s own column filters — the conditions its Filter menu lists — and count the chips with faceting, so the chips, the menu and the rows never disagree.
- Lock what the app owns or works out — SKU, committed, available — with
meta.readOnly, and work the computed columns out again inonDataChange. A list whose records change only through actions, like orders, isreadOnlyas a whole. - Confirm irreversible money actions — refunds, cancellations — with one
ConfirmDialog, whether they come from the bulk bar or from a single order. - Write both empty states — nothing yet, and nothing matching — under the grid: it draws neither.
Don't
- Do not rebuild the orders grid out of
<table>— you will re-implement combined filters, multi-key sorting, selection, pinning, column visibility, the clipboard and virtualisation, 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 draw a pill, an avatar or a link inside a grid cell. A cell is one of nine kinds so that it can be filtered, sorted, copied and pasted; draw the rich view in a panel that follows the focused row.
- Do not let an order be edited in its cells. Payment and fulfilment move through actions that write the order’s history, so the grid is read-only and the actions live in the bar and the panel.
- Do not count stock under a sort or a filter on Available or Stock. The grid re-sorts and re-filters the moment a count changes while the focus stays where it was, so the next keystroke lands on whichever line moved into its place. Count in an order the counts cannot change — the catalogue’s, or SKU — and sort by stock afterwards.
- Do not pass label objects just to get English. One
ComitorLocaleProvidersets the language for every component; a label prop is for your own wording. - Do not infer currency or country from the UI language — an English admin can sell in dong.
- Do not render a card number into your own page, masked or not. That is the payment provider’s field.
- Do not expose an average as a radiogroup.
- 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.
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. - A rating someone already gave carries a text equivalent — “Rated 4.8 out of 5 from 124 reviews” — and its stars are
aria-hiddenand out of the tab order: exposed, a read-only radiogroup would only repeat the number, with a tab stop on every card. - The review form’s Rating is a radiogroup named with
aria-labelledby— a<label htmlFor>cannot name one — and every star has a name of its own. The arrow keys choose as they move, Space or Enter picks the focused star, and Tab alone never rates. - 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. On the phone field the control props go onPhoneInputField, so the label names the number, and the country button names its value: “Country: United States (+1)”. - The checkout’s step list is a tablist: the current step carries
aria-current="step", a finished one reads “Step 1, completed”, and a Continue that fails moves focus to the first field in error instead of doing nothing audible. - 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. - Order progress and order history are Timelines — lists, with the current stage marked by
aria-current="step"rather than by colour. - Status is never colour alone: every pill carries its label, and the short form is only for columns too narrow for the full one.
- The orders and inventory grids are a
role="grid"each, witharia-rowcountand a name of their own: the arrow keys move between cells, Tab moves to the next cell, and Ctrl+C copies a selected range. In the inventory grid Enter or typing starts an edit of On hand or Reorder point — the locked columns open no editor — and Escape cancels it. Escape once more leaves the cell; from there Tab walks the orders grid’s row checkboxes — Space ticks one — and on out of the grid. The inventory grid has no checkboxes, so there Tab leaves at once. - The panel under the orders grid and the card under the inventory grid follow the focused row, so an order’s history, or a variant’s stock at every location, is one arrow key away rather than behind a click.
- An order’s own actions sit in that panel, behind a real
<button>named for the order — “Actions for order CMTR-29481” — not in a cell: a grid cell holds a value to reach and copy, not a control. - The bulk bar is a toolbar named “Bulk actions”, with the count (“3 orders selected”) and a named × (“Clear selection”). By default the package portals it to the end of
<body>— after the whole page in tab order — so the console passesportalContainerto put it right after the grid. Its results are announced by arole="status"line, because the bar has closed by then; the same line says when a refresh starts and ends. - The crop area is
role="application"with its keys described — arrows pan, Shift slows them, + and − zoom. Each specification row’s remove button names its row (“Remove Material”), and a “+2” in a tag cell is read as “and 2 more”. - Give every grid and table an
aria-label— “Orders”, “Inventory by variant”, “Catalogue” — when a screen holds more than one. The grid puts it on itsrole="grid"element, where a screen reader looks for it.