29 blocks, five stages, one purchase funnel. Every block maps to a decision a buyer makes. That is the organising principle — not "cards, forms, navigation", which is how generic kits are structured and why nobody can tell them apart.
Each entry carries two things worth more than the screenshot: the design decision that keeps it from looking generated, and the hard part that most component kits get wrong.
Effort is 1–3, roughly half a day to two days each.
Landing surfaces. Their only job is to make one thing look worth clicking.
Dismissible top-of-page ticker for shipping thresholds, sale windows and drops. Marquee or static.
- Design: inverted ink bar, no rounding, brand dot as separator. Marquee runs at 26s — slow enough to read, fast enough to notice.
- Hard part: respect
prefers-reduced-motion(freeze the marquee) and persist dismissal so it doesn't reappear on every route change. Shipped with a pause button too — moving content over five seconds needs one — and dismissal remembered perversion, so a new announcement returns. The marquee runs on Motion, so the block needs no keyframes in your CSS. - Composes:
button
Asymmetric split hero: oversized display headline left, full-bleed campaign image right with an overlaid product caption.
- Design: 1.05fr / 0.95fr split rather than 50/50 — the small imbalance is what stops it looking like a template. Caption sits on the image, not under.
- Hard part: LCP. The hero image is almost always the largest contentful
paint; ship
fetchpriority="high"and a real aspect-ratio box so it never shifts. No state and no"use client", so it renders on the server;renderImagefor next/image. - Composes: nothing — your buttons go in
actions
Horizontally scrolling, snap-aligned category tiles with numbered index and item counts.
- Design: tiles separated by 1px gaps over a border-coloured track, so the rail reads as one ruled object instead of floating cards.
- Hard part: scroll snapping plus keyboard access; arrow buttons must hide
when the rail isn't overflowing. Shipped on the Carousel, like
related-products: drag, swipe and arrow keys for free, and its buttons fade out and leave the tab order when it fits. - Composes:
carousel,skeleton(notscroll-area, whose scrollbar the house rules leave out)
Magazine-style editorial grid — one dominant tile plus two supporting tiles.
- Design: 12-column grid with a 7/5 split. Type scale changes with tile size; the big tile gets the bigger heading.
- Hard part: degrading to one column without the hierarchy collapsing into "three identical cards". On a phone the lead stays full width and tall and the other two pair up beneath it, smaller.
- Composes: nothing — CSS
aspect-ratiodoes whataspect-ratiowould, on both bases, and it renders on the server
Full-bleed campaign image with positioned hotspots opening a product hover card.
- Design: hotspots are hairline rings, not filled dots — they read as annotation.
- Hard part: hotspot coordinates must be percentage-based and re-anchored on
mobile, where the crop changes — each hotspot takes a
mobileposition formobileImage. - Composes:
popoveronly. A hover card can't be opened by touch or Enter, so the Popover opens on hover with a mouse (pointer events, with a grace period to cross to the card) and on tap or Enter otherwise; one primitive, the same on both bases.
The listing page. Where most of a store's revenue is actually decided.
The workhorse. Image, badges, brand, name, price with compare-at, colour
swatches that swap the image, rating, slide-up quick-add. Compound: one tag
for the default layout, or ProductCardMedia / ProductCardBody and their
parts in any order, all reading useProductCard().
- Design: quick-add slides up from the bottom edge of the image rather than floating over it, so it never covers the product.
- Hard part: quick-add must be keyboard-reachable
(
focus-visible:translate-y-0), and the card needs one link target — an::afteroverlay on the title anchor, not nested anchors. The parts share one context (selected colour, quick-add state) so a swatch can swap the image without either part knowing about the other. - Composes:
button,@ecomcn/price-tag
Responsive grid wrapper with loading skeletons, appended skeletons while more
results load, a density switch and a renderCard slot for composed cards.
- Design: column gap tighter than row gap (20/36px) — products group by row the way they do on a page of a printed catalogue.
- Hard part: skeletons must match the real card's box exactly or the grid jumps on load. Never a spinner here.
- Composes:
skeleton,@ecomcn/product-card
Applied-filter chips, checkbox lists with counts, a dual-handle range with typed
inputs, colour swatches and toggles. Ships lib/filter-params.ts (pure parse /
serialise, safe in server components) and hooks/use-filter-params.ts.
- Design: groups separated by top rules, not cards. Counts tabular-aligned to the right edge. Swatches print their name; selection adds a check and a heavier border.
- Hard part: filter state belongs in the URL, not React state — deep links and back-button behaviour are the whole point of a listing page. The slider commits on release, and an unchanged state never pushes a history entry.
- Composes:
checkbox,slider,switch,input
Progress rule, "Showing 24 of 312", a Load more button, and optional hybrid or infinite loading.
- Design: the top hairline is the progress bar, filled to the share already shown. Count left, action right — it reads as the foot of the listing.
- Hard part: Baymard found Load more beats pagination and pure infinite
scroll, but only with the details: focus moves to the first new product after
a click, the new count is announced,
?page=3restores the same depth, and hybrid mode auto-loads only after the shopper has asked once. - Composes:
button
The card's image morphs into a dialog and back — a shared-element transition.
- Design: one morph, used where it carries meaning; ease-out with no bounce. The panel surface fades on its own layer, so the moving image is never inside something that is also fading.
- Hard part: the dialog, not the morph — native
<dialog>for the top layer and an inert page, Escape that plays the closing morph, focus returned only after the image lands, no movement at all under reduced motion. - Composes:
button,@ecomcn/price-tag,@ecomcn/product-card
The same facets in a left slide-over with a sticky Clear / Show N footer.
- Design: the footer sits outside the scroll area, so its rule runs full width and the primary action never scrolls away.
- Hard part: mobile filters stage changes and apply once, on Show or on
close — applying live re-fetches on every tap. Uses a real
SheetTrigger(styled withbuttonVariants, noasChild) so focus returns on close in both Radix and Base UI. - Composes:
sheet,button,@ecomcn/filter-panel
Result count, sort select, density toggle, and a slot for the mobile filter trigger.
- Design: bounded top and bottom by hairlines so it reads as a masthead rule
across the listing. Sort is a native
<select>— the OS picker beats any popover on a phone. - Hard part: announce result-count changes with an
aria-liveregion — silent on first render and while loading, once per burst of changes. - Composes: nothing. The filter trigger is a slot, so installing the toolbar doesn't drag in the whole filter stack.
No-match state that names the offending filters and offers the one useful escape.
- Design: left-aligned, not centred. Centred empty states are the most reliable tell of a generated UI.
- Hard part: be specific — "no results" is useless; "nothing matches “Sage” and “Up to $50” — remove “Up to $50” to see 4" converts. The suggestion is data, so the count can come from your search backend.
- Composes:
button
The detail page. Every objection a buyer has gets answered here or nowhere.
Thumbnail rail plus main image, counter overlay, horizontal thumb scroll on mobile.
- Design: active thumb gets a solid border; inactive thumbs drop to 60% opacity instead of being greyed.
- Hard part: swipe on mobile, hover-zoom on desktop,
aria-currenton the active thumb. Preload only image 2. Embla drags only for touch, so a mouse is free to zoom; pressing the photo opens it full screen, which is the zoom for touch and the keyboard — nothing is hover-only. - Composes:
carousel,dialog(the frame's ratio is plain CSSaspect-ratio, so theaspect-ratioprimitive isn't needed)
Locale-aware price with compare-at strike, computed discount badge, two sizes.
- Design: tabular numerals everywhere. A discounted price turns sale-red; a regular price stays ink.
- Hard part:
Intl.NumberFormatwith currency + locale as props. Hardcoding$is the single most common bug in component kits. - Composes: nothing — pure Tailwind, zero registry dependencies
Colour swatches and a size grid with out-of-stock strike-through and low-stock messaging, worked out from the product's variants.
- Design: the size grid is one ruled block — hairline cells rather than five buttons — and the pick is a slab of ink that slides from cell to cell on the house settle curve. "2 left" sits under a size that is running out.
- Hard part: out-of-stock must be visible without colour alone (the diagonal rule) and must stay focusable so screen readers hear it's unavailable. It is never disabled: a sold-out pick is allowed, for a notify-me, and the status line says why it can't be bought. The arrow keys move without choosing, so arrowing past a colour never swaps the photo.
- Composes: no primitives (a hand-rolled radio group, identical on both
bases); the size guide goes in through the
sizeGuideslot
Measurement table plus a fit note, opened from the size label.
- Design: header row uses the eyebrow style; the table is ruled, not striped. The panel grows out of the "Size guide" link and folds back into it — a container transform, contents fading in once the surface has landed.
- Hard part: tables need horizontal scroll containment on narrow screens, with the size column pinned; no scrollbars drawn, a soft edge instead.
- Composes:
dialog,table
Title, price, rating link, variants, quantity stepper, add-to-bag with live total, wishlist, delivery promise. The flagship — the block people screenshot.
- Design: the CTA carries the price. "ADD TO BAG — $420" outperforms a bare "Add to cart" because it removes a mental step.
- Hard part: optimistic add-to-bag with a rollback path, and a delivery estimate that's a date range, not "ships soon". The button says "Added" at once and rolls back with a line under it if the handler rejects; a missing size is asked for, not greyed out. The dates are worked out on the shopper's clock after hydration. The selected variant lives in one context, so the gallery can read it.
- Composes:
button,input,@ecomcn/price-tag,@ecomcn/variant-swatches
Materials, care, shipping and maker sections, first open by default.
- Design: triggers use the tracked-out eyebrow with a one-line summary beside them; body copy muted at 14px/1.7. Panels grow to their measured height with Motion, and the chevron turns with them.
- Hard part: shadcn's Accordion roots disagree (
type="multiple"on Radix,multipleon Base UI), and Base UI's prop leaks into Radix's DOM and breaks hydration. The block keeps the value controlled, toggles from each trigger, and holds a closing section open until its panel has animated shut. Closed content stays findable: the summary line is always on the page, andcontentProps={{ forceMount: true }}(Radix) orkeepMounted(Base UI) keeps whole panels in the HTML. - Composes:
accordion
Average score, star distribution histogram, fit slider, review list with verified badges.
- Design: the average is set in the display serif at 60px — the number is the headline, not a label.
- Hard part: histogram bars must be buttons that filter the list, and the fit slider needs a text equivalent. Each row reads "5 stars, 226 reviews, 72%"; the fit's reading is on screen in words. Dates are formatted in UTC so a calendar date never shifts by a day.
- Composes:
button,badge(the bars are drawn, notprogress: a progressbar inside a button would be announced as one)
Snap-scrolling "pairs well with" rail built from product cards.
- Design: reuses the exact product card — never build a second, smaller card.
- Hard part: lazy-load below the fold; this rail is pure LCP tax otherwise. Cards mount within a screen of the viewport, over skeletons of their shape.
- Composes:
carousel,skeleton,@ecomcn/product-card
Pure friction removal. Every element either reduces doubt or gets cut.
Thumbnail, variant line, quantity stepper, line total, save-for-later, remove.
- Design: line total right-aligned and tabular so a column stacks into a readable ledger.
- Hard part: removal needs an undo, not a confirm dialog. Quantity changes must debounce before hitting the cart API.
- Composes:
button,input
Slide-over mini cart with scrollable lines, sticky subtotal footer, empty state.
- Design: header, body and footer are ruled bands; the body is the only scroller.
- Hard part: focus trapping, restoring focus to the bag trigger on close, and not remounting on every cart mutation.
- Composes:
sheet,button,@ecomcn/cart-line-item(native scrolling with soft edges instead ofscroll-area, which draws a scrollbar)
Free-shipping progress, promo code with applied state, itemised totals, CTA.
- Design: total set in the display serif at 30px against a heavy rule — the only thing in the panel that shouts.
- Hard part: tax and shipping are estimates until an address exists. Say so, or you generate support tickets.
- Composes:
input,button,progress
Four-step progress rail with done / active / pending states.
- Design: equal-width ruled cells edge to edge — a rail, not floating pills.
- Hard part: completed steps must be clickable to go back; pending ones must
not be. Use
aria-current="step". - Composes: nothing
Delivery methods with their arrival dates and prices. Not in the original plan: the demo store's Delivery step needed a block, and the date logic already existed in the buy box.
- Design: one ruled list, not floating cards. Arrival is each row's headline — "Arrives Thu 16 – Mon 20 Oct" — and prices form a right-aligned tabular column; a free method says "Free" in words. A heavy rule slides to the chosen method.
- Hard part: dates the server can't know. Business days from dispatch, and dispatch is today only before the cut-off — on the shopper's clock and time zone, so worked out after hydration with a same-height placeholder until then. The chosen method counts down to its cut-off.
- API:
options: { id, label, price, minDays, maxDays, cutoffHour?, businessDaysOnly?, description?, available?, unavailableReason? }[], controlledvalue/onValueChange,currency/locale,loading.deliveryWindow(option, now)is exported for the same dates elsewhere. - Composes:
radio-group,skeleton.
Radio cards for card / wallet / pay-in-3 / bank, each expanding to the provider's own fields.
- Design: one ruled list. The chosen method and its panel share a background and a heavy rule that slides between methods — one object, not two — and the panel grows to its measured height (Motion; a fade under reduced motion).
- Hard part: never take raw PAN. The block renders no card-number,
expiry or CVC input. Each method's panel is a provider slot —
renderPanel(method)— where you mount Stripe's Payment Element, Adyen's Drop-in, PayPal's buttons, or a redirect notice. A provider iframe must not be remounted by an unrelated re-render, or the shopper's half-typed card vanishes: panels are keyed by method id and stay mounted once opened (keepMounted, on by default), hidden rather than destroyed. - API sketch:
methods: { id, label, description?, icon?, kind: "card" | "wallet" | "bnpl" | "bank" | "other", available?: boolean, unavailableReason? }[], controlledvalue/onValueChange,renderPanel, andtotalwithcurrency/localefor the instalment line ("3 payments of $140", throughIntl.NumberFormat).errorshows a provider failure inside the panel. - States: loading (methods not known yet); a method unavailable on this device — Apple Pay in Firefox, say — shown with its reason in words, not greyed out alone; a provider error; nothing selected yet.
- Accessibility: one radio group with roving focus; each method's panel
is tied to its radio with
aria-controls; focus stays on the radio when a panel opens. The panels are outside the radio group in the DOM — Base UI's group takes arrow keys from anything inside it, so a provider's<select>would change the method — and a CSS subgrid lays each one under its own method. Tab order runs group → open panel. - Composes:
radio-group,skeleton. Noinput— the provider brings the fields.
Country-aware shipping or billing address, with an address-lookup slot and save-as-default.
- Design: side by side when the form is wide enough (container queries, so a narrow checkout column gets one column), one on a phone; labels above the field, never floating or placeholder-only. Fields that appear or vanish when the country changes grow and fold to their measured height (Motion; a fade under reduced motion).
- Hard part: address shape differs by country. A format table drives the
field set, order, labels ("ZIP code" / "Postcode" / "PIN code"), which
fields are required, the region list (US states, Canadian provinces,
Australian and Indian states) and a postcode pattern; an unknown country
falls back to a generic shape. Every field carries the right
autocompletetoken, prefixed with the section —shipping given-name,shipping address-line1,shipping address-level2(city),shipping address-level1(region),shipping postal-code,shipping country,shipping tel— so browser autofill works first time. - API sketch: controlled
value: Address/onValueChange,countrydriving the format,section: "shipping" | "billing",errorskeyed by field (yours — from your server or your schema),formatsto add or override countries, alookupslot above the fields for Google Places / Loqate / your own search (it fills the form withuseAddressForm().setAddress),fieldsto show, hide or require company, second line and phone, andonSaveAsDefaultChange. A purevalidateAddress(address, format)lives inlib/address-format.ts, a file with no"use client", so the same rules run on the server. No form library is required. - States: loading skeleton in the form's shape; disabled; an error summary
at the top on submit that links to each field (
role="alert"), plus the message under each field witharia-describedby. - Composes:
field,input,checkbox(save as default),skeleton, andcomboboxfor country and region. Notselect: Radix's and Base UI's Select differ too much to share one file. Notdropdown-menu: a menu is for actions, not a form value — no selected state for screen readers, no search, no autofill. shadcn's Combobox is Base UI in every style, Radix included, so one file works on both; it searches ~250 countries as you type, and its hidden input takes the browser's autofill, matching "US" or "United States". Matches rank code, then prefix, then word, then anywhere — "ger" is Germany, not Algeria.
The part almost every component kit skips — and the part that drives repeat orders.
Success banner, order number, delivery address, arrival date, amount paid, next actions.
- Design: the thank-you is 48px in your serif (
font-serif) on a grained band — an inline SVG noise, no image request — the one moment in the funnel that earns a flourish, with a check that draws itself. The four facts people look for sit in one ruled row above the fold. - Hard part: this page is the receipt users screenshot. It prints as a plain black-on-white receipt (no band, no buttons, the permalink written out); the permalink is the adopter's — a token link that shows the order from any device, never session state — and "Copy order link" falls back to selecting the link when the clipboard is refused.
- Composes:
button,skeleton
Vertical shipment timeline with a live-node ring and carrier status chip.
- Design: the current node gets a ring in
primary(not a brand token an adopter may not have) and a live pulse; completed nodes are solid ink with a check; pending are border-grey. The rule fills as the parcel moves. - Hard part: carrier webhooks arrive out of order — sort by event time, not
arrival time — and twice: de-duplicate by id, the last copy winning. The
logic is in
lib/tracking-events.ts, with no"use client", so the webhook handler runs the same code. A problem after the last milestone shows until the next one. - Composes:
badge,skeleton
| Release | Blocks |
|---|---|
| v1.0 — Checkout | address-form · delivery-options · payment-selector — Milestone 5, built early, shipped in 1.0.0 |
| v1.1 — Post-purchase & Discover | order-confirmation · order-tracking · announcement-bar · hero-editorial · category-rail · collection-grid · lookbook-strip — Milestones 6 and 7 in ROADMAP.md, done: the whole catalogue |
| v1.2 — Merchandising | bundle-builder · recently-viewed · trust-badges-row · newsletter-capture · back-in-stock-form · gift-card-block |
| v1.3 — Account | order-history-list · wishlist-grid · returns-request-flow · subscription-manager · saved-addresses · loyalty-tier-card |
| v1.4 — Ops surfaces | admin-orders-table · inventory-editor · product-form · discount-builder · sales-stat-row · fulfilment-queue |
| v1.5 — Theming | theme-editorial (default) · theme-utility (dense B2B) · theme-boutique (soft DTC) |