Skip to content

Latest commit

 

History

History
434 lines (383 loc) · 24.4 KB

BLOCK-CATALOG.md

File metadata and controls

434 lines (383 loc) · 24.4 KB

ecomcn — v1 block catalogue

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.


Stage 1 — Discover

Landing surfaces. Their only job is to make one thing look worth clicking.

announcement-bar · component · effort 1 · shipped · motion

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 per version, so a new announcement returns. The marquee runs on Motion, so the block needs no keyframes in your CSS.
  • Composes: button

hero-editorial · block · effort 2 · shipped

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; renderImage for next/image.
  • Composes: nothing — your buttons go in actions

category-rail · block · effort 2 · shipped · motion

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 (not scroll-area, whose scrollbar the house rules leave out)

collection-grid · block · effort 2 · shipped

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-ratio does what aspect-ratio would, on both bases, and it renders on the server

lookbook-strip · block · effort 3 · shipped · motion

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 mobile position for mobileImage.
  • Composes: popover only. 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.

Stage 2 — Browse

The listing page. Where most of a store's revenue is actually decided.

product-card · component · effort 2 · shipped

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 ::after overlay 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

product-grid · block · effort 1 · shipped

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

filter-panel · block · effort 3 · shipped

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

load-more · component · effort 2 · shipped

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=3 restores the same depth, and hybrid mode auto-loads only after the shopper has asked once.
  • Composes: button

product-quick-view · block · effort 2 · shipped · depends on motion

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

filter-sheet · block · effort 2 · shipped

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 with buttonVariants, no asChild) so focus returns on close in both Radix and Base UI.
  • Composes: sheet, button, @ecomcn/filter-panel

sort-toolbar · component · effort 1 · shipped

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-live region — 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.

empty-results · component · effort 1 · shipped

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

Stage 3 — Product

The detail page. Every objection a buyer has gets answered here or nowhere.

product-gallery · block · effort 3 · shipped · motion

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-current on 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 CSS aspect-ratio, so the aspect-ratio primitive isn't needed)

price-tag · ui · effort 1

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.NumberFormat with currency + locale as props. Hardcoding $ is the single most common bug in component kits.
  • Composes: nothing — pure Tailwind, zero registry dependencies

variant-swatches · block · effort 3 · shipped · motion

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 sizeGuide slot

size-guide-dialog · component · effort 1 · shipped · motion

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

product-buy-box · block · effort 3 · shipped · motion

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

product-details-accordion · component · effort 1 · shipped · motion

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, multiple on 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, and contentProps={{ forceMount: true }} (Radix) or keepMounted (Base UI) keeps whole panels in the HTML.
  • Composes: accordion

review-summary · block · effort 3 · shipped · motion

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, not progress: a progressbar inside a button would be announced as one)

related-products · block · effort 1 · shipped · motion

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

Stage 4 — Cart & checkout

Pure friction removal. Every element either reduces doubt or gets cut.

cart-line-item · component · effort 2 · shipped · motion

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

cart-sheet · block · effort 2 · shipped · motion

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 of scroll-area, which draws a scrollbar)

order-summary · block · effort 3

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

checkout-stepper · component · effort 1 · shipped · motion

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-options · block · effort 1 · v1.1 · shipped · motion

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? }[], controlled value / onValueChange, currency / locale, loading. deliveryWindow(option, now) is exported for the same dates elsewhere.
  • Composes: radio-group, skeleton.

payment-selector · block · effort 3 · v1.1 · shipped · motion

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? }[], controlled value / onValueChange, renderPanel, and total with currency / locale for the instalment line ("3 payments of $140", through Intl.NumberFormat). error shows 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. No input — the provider brings the fields.

address-form · block · effort 3 · v1.1 · shipped · motion

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 autocomplete token, 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, country driving the format, section: "shipping" | "billing", errors keyed by field (yours — from your server or your schema), formats to add or override countries, a lookup slot above the fields for Google Places / Loqate / your own search (it fills the form with useAddressForm().setAddress), fields to show, hide or require company, second line and phone, and onSaveAsDefaultChange. A pure validateAddress(address, format) lives in lib/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 with aria-describedby.
  • Composes: field, input, checkbox (save as default), skeleton, and combobox for country and region. Not select: Radix's and Base UI's Select differ too much to share one file. Not dropdown-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.

Stage 5 — Post-purchase

The part almost every component kit skips — and the part that drives repeat orders.

order-confirmation · block · effort 2 · shipped · motion

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

order-tracking · block · effort 2 · shipped · motion

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

After v1

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)