product-detail — where the buy decision is made
Stage: Phase 6 — Build - Reads: design/DIRECTION.md, design/SYSTEM.md, design/SITEMAP.md, design/BRIEF.md - Writes: app/(shop)/products/[slug]/page.tsx, components/pdp/*
Standard
The PDP is the page where the visitor decides. First-grade means: every variant is visible before commitment, and no full-page reload is needed to see a different option. Concretely — one aspect-locked gallery with real zoom, a buy-box whose hierarchy reads top-to-bottom as name → price → variant → one primary CTA, a spec sheet that beats a bullet list, trust placed where doubt peaks, and a mobile buy-box that stays reachable. The page shell is a React Server Component; only the variant island is "use client" (ultraweb:app-structure). One content dependency is non-negotiable and gets flagged in BRIEF.md up front: one pre-optimized photo per variant — the anchor pattern below is worthless without it.
Process
- Read the wireframe for section order and BRIEF.md for the per-variant photo inventory; if one shot per variant is missing, flag it before building — not after.
- Lock ONE gallery aspect ratio for the whole product range; wire the main image as the LCP (
preload, sizes, placeholder="blur").
- Build the buy-box hierarchy name → price → variant → CTA with exactly one primary button.
- Implement the Swatch-Driven Hero Crossfade as the single client island: server picks the default variant from
searchParams; selection crossfades, re-prices, writes the URL, and announces via aria-live.
- Spec via
data-display Definition, trust via social-proof, then the mobile sticky buy-box bar.
- Optional riders (below) only if the brief asks. Verify: no reload on select, zero CLS on crossfade, AA, reduced-motion path, both themes.
The signature — Swatch-Driven Hero Crossfade
The anchor. Convincing material choice does not require real-time rendering: a well-shot photo per swatch, crossfaded on selection, delivers the perceived craft of a full configurator at near-zero cost (award-canon — steal the principle, skip the WebGL). Vitra, Herman Miller, and IKEA all ship exactly this. The server component reads searchParams (a Promise in Next 16) to choose the SSR-correct default variant and hands it to one client island; selection then (a) crossfades the gallery, (b) re-prices inline for any upcharge, (c) writes ?variant= via router.replace(..., { scroll: false }) so the choice is shareable and back-button-able — never a navigation, never a refetch.
"use client"; // components/pdp/variant-gallery.tsx — the ONLY client island on the PDP
export function VariantGallery({ variants, initial }: { variants: Variant[]; initial: string }) {
const router = useRouter();
const active = variants.find(v => v.slug === (useSearchParams().get("variant") ?? initial))!;
return (
<>
<div className="relative aspect-[4/5]"> {/* aspect LOCKED → zero CLS on swap */}
{variants.map(v => (
<Image key={v.slug} src={v.hero} alt={v.alt} fill sizes="(max-width:768px) 100vw, 55vw"
placeholder="blur" preload={v.slug === initial} {/* preload initial only; priority is deprecated */}
className="object-cover transition-opacity duration-[--dur-micro] ease-[--ease-standard]"
style={{ opacity: v.slug === active.slug ? 1 : 0 }} /> {/* crossfade, not a hard cut */}
))}
</div>
<fieldset className="mt-4">
<legend className="sr-only">Colour</legend>
{variants.map(v => (
<label key={v.slug} data-checked={v.slug === active.slug}
className="... data-[checked=true]:ring-2 data-[checked=true]:ring-ring">
<input type="radio" name="variant" value={v.slug} checked={v.slug === active.slug} className="sr-only"
=> router.replace(`?variant=${v.slug}`, { scroll: false })} />
<span aria-hidden style={{ background: v.chip }} /><span className="sr-only">{v.name}</span>
</label>
))}
</fieldset>
</>
);
}
- Swatches are native radios, visually hidden, chip rendered on the
<label> — arrow-key navigation and the checked state come free; the selected chip gets the ring (--ring). A 40px thumbnail (a real fabric/finish crop, not a flat colour) is the honest choice for textiles; a solid --chip swatch suits hard goods.
- Crossfade = two stacked
fill images in the aspect box, one motion-language duration token (micro band, ~200ms), opacity only — no motion library, no hard swap. Under prefers-reduced-motion it collapses to an instant swap.
- Out-of-stock variants stay visible, disabled with a struck ring — hiding a colour erases the range the shopper came to see.
- Prefetch the non-initial hero images on first swatch focus/hover so the first crossfade is instant; only the initial variant's image is
preloaded (it's the PDP's LCP).
Media gallery
- Aspect discipline: one ratio for the entire product range (4/5 for apparel/textiles, 1/1 for objects), locked so neither a swatch crossfade nor a thumbnail swap shifts layout. Shoot every variant to that ratio.
- Thumbnails vs. swatches are different jobs: a swatch changes the variant (crossfade); a thumbnail rail changes the angle within the current variant (instant swap). Rail is vertical beside the stage on desktop, a horizontal scroll-snap strip on mobile — each thumb a real
<button> named by its angle ("Detail weave"), not a bare dot.
- Zoom is real, not decorative: click-to-open a focus-trapped lightbox (
ultraweb:overlays) showing the media-optimization 2x asset — the baseline every product buyer expects. A hover-magnify lens is an optional desktop upgrade, never the only zoom (touch has no hover). No auto-pan under reduced-motion.
The buy-box
Hierarchy is fixed, because it maps to how the decision forms: product name (the page <h1>) → price → one value line → variant picker (colour swatches + size) → quantity → Add to cart → shipping/returns microcopy → collapsible spec. Rules:
- Exactly one primary CTA — the filled "Add to cart". Save/wishlist is a ghost or icon button, never a second filled button; a "Buy now" express path, if any, is secondary (
ultraweb:buttons). Add-to-cart itself — the mutation, the optimistic count, the drawer — belongs to ultraweb:cart; the PDP owns the selected-variant state and the button, cart owns what happens on click.
- Size selector is a segmented chip group beside the swatches; out-of-stock sizes render disabled, and selecting a size resolves the final SKU and availability.
- Price uses
data-display alignment — tabular-nums, one currency format via Intl.NumberFormat. On an upcharge variant the price re-renders and shows the delta ("+€20") in the same tabular figures; the whole price/availability block sits in aria-live="polite" so a swatch change is announced.
- Desktop stickiness: the buy-box column is
position: sticky; top: … so a long gallery or spec scroll never carries the CTA off-screen.
- DACH price display (Grundpreis per kg/l/m, "inkl. MwSt.", shipping-cost link) is a legal slot the buy-box reserves but does not author — the exact strings and format defer to
ultraweb:pricing and ultraweb:gate-content. Never invent the MwSt/Grundpreis line inline.
Spec, description & trust
- Spec sheet via the
data-display Definition variant — a two-column label/value table (Material, Dimensions, Weight, Care, Origin), one precision per column, units stated once. It beats a bulleted list every time.
- Description is real copy (
ultraweb:copywriting), never lorem; long prose and the spec go in an accordion or tabs so the buy-box clears the fold on mobile.
- Trust content is
ultraweb:social-proof's job; the PDP owns placement: a compact star rating beside the price that anchor-links to the review block (one proof element that supports the CTA, not competes with it), the full reviews lower, customer photos passed through ultraweb:imagery's treatment, and payment/returns badges from ultraweb:payments near the CTA where purchase doubt peaks.
Mobile buy-box stickiness
On mobile the buy-box can't be a sticky side column. Ship a sticky bottom action bar that appears once the inline Add-to-cart scrolls out of view: compact price + current variant summary + the same primary CTA. It respects env(safe-area-inset-bottom), reserves page padding so it never covers content, and hides when the cart drawer opens. Reduced-motion: it appears without the slide. This bar is the mobile conversion anchor — most mobile add-to-carts happen here, not at the inline button.
Optional riders (P2 — build only if the brief calls for them)
- Guided-selling quiz-to-cart — a 2–4 step chooser ("what are you making?" → recommends a variant/bundle → pre-fills the buy-box or adds to cart) for choice-heavy catalogues. It is an addition, never a gate: the direct PDP always works without it.
- Digital Product Passport panel — for textiles, the EU ESPR Digital Product Passport (fibre composition, recycled content, care/repair, origin, plus a scannable code) is phasing in later this decade. Render it as a
data-display Definition panel with a QR/link, populated from real data only and honestly stubbed until the passport exists — never fabricated compliance text. Treat it as an optional section here; the legal data model is out of this skill's scope.
A11y
- Swatches in a
<fieldset><legend> as native radios (arrow-key group nav, :checked for free); each label's accessible name is the variant name, never just a colour chip.
- The price + availability region is
aria-live="polite" so screen-reader users hear the update after selecting a variant; the gallery alt describes the current variant ("Aran throw in Loden, draped").
- Zoom lightbox is a
role="dialog", focus-trapped, Esc-closable, focus returned on close (ultraweb:overlays).
- A disabled Add-to-cart (no size chosen) carries a reason, not a dead button; the mobile sticky bar's CTA is the same accessible action, not a focus trap.
Anti-patterns
- Variant select that navigates or refetches — a per-colour
<a href>, or a router.push that re-renders the page. Variant change is a client-side crossfade, no reload.
- Hiding out-of-stock variants instead of disabling them — the shopper can't judge a range they can't see.
- Two filled CTAs (Add to cart + Buy now both filled); "Add to cart" competing with a filled wishlist.
- Price or delta without
tabular-nums; a hand-formatted currency string instead of Intl.NumberFormat.
- Swatches as
<div onClick> — no radio semantics, no keyboard, no aria-checked, no ring.
- Gallery aspect not locked → layout shift on crossfade (CLS);
priority on the gallery image (deprecated — use preload); loading="lazy" on the LCP gallery image.
- Emoji as swatch/size markers; a gray-box placeholder for a missing variant photo (
ultraweb:imagery owns honest placeholders).
- A carousel as the only gallery — no thumbnail affordance, no zoom.
- Mobile sticky bar covering content (no safe-area, no reserved padding).
- Inventing the Grundpreis/MwSt line inline instead of deferring to
pricing/gate-content.
- Reaching for a motion library to fade one image — CSS opacity is enough.
Worked example — Loop & Thread, the Aran Lambswool Throw
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: product-detail3description: Design and build the product detail page (PDP) — media gallery, the buy-box hierarchy (name → price → variant picker → one Add-to-cart), spec sheet, and trust signals — anchored by the Swatch-Driven Hero Crossfade — selecting a colour/material swatch crossfades the gallery, updates price inline, and writes URL state with no full reload and no WebGL. Invoke in Phase 6 for any product page — trigger phrases — "product page", "PDP", "product detail", "variant picker", "colour swatches", "size selector", "add-to-cart layout", "product gallery", "material preview", "lightweight configurator".4---56# product-detail — where the buy decision is made78**Stage:** Phase 6 — Build - **Reads:** design/DIRECTION.md, design/SYSTEM.md, design/SITEMAP.md, design/BRIEF.md - **Writes:** app/(shop)/products/[slug]/page.tsx, components/pdp/*910## Standard1112The PDP is the page where the visitor decides. First-grade means: **every variant is visible before commitment, and no full-page reload is needed to see a different option.** Concretely — one aspect-locked gallery with real zoom, a buy-box whose hierarchy reads top-to-bottom as name → price → variant → one primary CTA, a spec sheet that beats a bullet list, trust placed where doubt peaks, and a mobile buy-box that stays reachable. The page shell is a React Server Component; only the variant island is `"use client"` (`ultraweb:app-structure`). One content dependency is non-negotiable and gets flagged in BRIEF.md up front: **one pre-optimized photo per variant** — the anchor pattern below is worthless without it.1314## Process15161. Read the wireframe for section order and BRIEF.md for the per-variant photo inventory; if one shot per variant is missing, flag it before building — not after.172. Lock ONE gallery aspect ratio for the whole product range; wire the main image as the LCP (`preload`, `sizes`, `placeholder="blur"`).183. Build the buy-box hierarchy name → price → variant → CTA with exactly one primary button.194. Implement the Swatch-Driven Hero Crossfade as the single client island: server picks the default variant from `searchParams`; selection crossfades, re-prices, writes the URL, and announces via `aria-live`.205. Spec via `data-display` Definition, trust via `social-proof`, then the mobile sticky buy-box bar.216. Optional riders (below) only if the brief asks. Verify: no reload on select, zero CLS on crossfade, AA, reduced-motion path, both themes.2223## The signature — Swatch-Driven Hero Crossfade2425The anchor. Convincing material choice does **not** require real-time rendering: a well-shot photo per swatch, crossfaded on selection, delivers the perceived craft of a full configurator at near-zero cost (`award-canon` — steal the principle, skip the WebGL). Vitra, Herman Miller, and IKEA all ship exactly this. The server component reads `searchParams` (a Promise in Next 16) to choose the SSR-correct default variant and hands it to one client island; selection then (a) crossfades the gallery, (b) re-prices inline for any upcharge, (c) writes `?variant=` via `router.replace(..., { scroll: false })` so the choice is shareable and back-button-able — never a navigation, never a refetch.2627```tsx28"use client"; // components/pdp/variant-gallery.tsx — the ONLY client island on the PDP29export function VariantGallery({ variants, initial }: { variants: Variant[]; initial: string }) {30 const router = useRouter();31 const active = variants.find(v => v.slug === (useSearchParams().get("variant") ?? initial))!;32 return (33 <>34 <div className="relative aspect-[4/5]"> {/* aspect LOCKED → zero CLS on swap */}35 {variants.map(v => (36 <Image key={v.slug} src={v.hero} alt={v.alt} fill sizes="(max-width:768px) 100vw, 55vw"37 placeholder="blur" preload={v.slug === initial} {/* preload initial only; priority is deprecated */}38 className="object-cover transition-opacity duration-[--dur-micro] ease-[--ease-standard]"39 style={{ opacity: v.slug === active.slug ? 1 : 0 }} /> {/* crossfade, not a hard cut */}40 ))}41 </div>42 <fieldset className="mt-4">43 <legend className="sr-only">Colour</legend>44 {variants.map(v => (45 <label key={v.slug} data-checked={v.slug === active.slug}46 className="... data-[checked=true]:ring-2 data-[checked=true]:ring-ring">47 <input type="radio" name="variant" value={v.slug} checked={v.slug === active.slug} className="sr-only"48 onChange={() => router.replace(`?variant=${v.slug}`, { scroll: false })} />49 <span aria-hidden style={{ background: v.chip }} /><span className="sr-only">{v.name}</span>50 </label>51 ))}52 </fieldset>53 </>54 );55}56```5758- **Swatches are native radios**, visually hidden, chip rendered on the `<label>` — arrow-key navigation and the checked state come free; the selected chip gets the ring (`--ring`). A 40px thumbnail (a real fabric/finish crop, not a flat colour) is the honest choice for textiles; a solid `--chip` swatch suits hard goods.59- **Crossfade** = two stacked `fill` images in the aspect box, one motion-language duration token (micro band, ~200ms), opacity only — no motion library, no hard swap. Under `prefers-reduced-motion` it collapses to an instant swap.60- **Out-of-stock variants stay visible**, disabled with a struck ring — hiding a colour erases the range the shopper came to see.61- Prefetch the non-initial hero images on first swatch focus/hover so the first crossfade is instant; only the initial variant's image is `preload`ed (it's the PDP's LCP).6263## Media gallery6465- **Aspect discipline:** one ratio for the entire product range (4/5 for apparel/textiles, 1/1 for objects), locked so neither a swatch crossfade nor a thumbnail swap shifts layout. Shoot every variant to that ratio.66- **Thumbnails vs. swatches are different jobs:** a swatch changes the *variant* (crossfade); a thumbnail rail changes the *angle within the current variant* (instant swap). Rail is vertical beside the stage on desktop, a horizontal scroll-snap strip on mobile — each thumb a real `<button>` named by its angle ("Detail weave"), not a bare dot.67- **Zoom is real, not decorative:** click-to-open a focus-trapped lightbox (`ultraweb:overlays`) showing the media-optimization 2x asset — the baseline every product buyer expects. A hover-magnify lens is an optional desktop upgrade, never the only zoom (touch has no hover). No auto-pan under reduced-motion.6869## The buy-box7071Hierarchy is fixed, because it maps to how the decision forms: **product name (the page `<h1>`) → price → one value line → variant picker (colour swatches + size) → quantity → Add to cart → shipping/returns microcopy → collapsible spec.** Rules:7273- **Exactly one primary CTA** — the filled "Add to cart". Save/wishlist is a ghost or icon button, never a second filled button; a "Buy now" express path, if any, is secondary (`ultraweb:buttons`). Add-to-cart itself — the mutation, the optimistic count, the drawer — belongs to `ultraweb:cart`; the PDP owns the selected-variant state and the button, cart owns what happens on click.74- **Size selector** is a segmented chip group beside the swatches; out-of-stock sizes render disabled, and selecting a size resolves the final SKU and availability.75- **Price** uses `data-display` alignment — `tabular-nums`, one currency format via `Intl.NumberFormat`. On an upcharge variant the price re-renders and shows the delta ("+€20") in the same tabular figures; the whole price/availability block sits in `aria-live="polite"` so a swatch change is announced.76- **Desktop stickiness:** the buy-box column is `position: sticky; top: …` so a long gallery or spec scroll never carries the CTA off-screen.77- **DACH price display** (Grundpreis per kg/l/m, "inkl. MwSt.", shipping-cost link) is a legal slot the buy-box *reserves* but does not author — the exact strings and format defer to `ultraweb:pricing` and `ultraweb:gate-content`. Never invent the MwSt/Grundpreis line inline.7879## Spec, description & trust8081- **Spec sheet** via the `data-display` Definition variant — a two-column label/value table (Material, Dimensions, Weight, Care, Origin), one precision per column, units stated once. It beats a bulleted list every time.82- **Description** is real copy (`ultraweb:copywriting`), never lorem; long prose and the spec go in an accordion or tabs so the buy-box clears the fold on mobile.83- **Trust** content is `ultraweb:social-proof`'s job; the PDP owns *placement*: a compact star rating beside the price that anchor-links to the review block (one proof element that supports the CTA, not competes with it), the full reviews lower, customer photos passed through `ultraweb:imagery`'s treatment, and payment/returns badges from `ultraweb:payments` near the CTA where purchase doubt peaks.8485## Mobile buy-box stickiness8687On mobile the buy-box can't be a sticky side column. Ship a **sticky bottom action bar** that appears once the inline Add-to-cart scrolls out of view: compact price + current variant summary + the same primary CTA. It respects `env(safe-area-inset-bottom)`, reserves page padding so it never covers content, and hides when the cart drawer opens. Reduced-motion: it appears without the slide. This bar is the mobile conversion anchor — most mobile add-to-carts happen here, not at the inline button.8889## Optional riders (P2 — build only if the brief calls for them)9091- **Guided-selling quiz-to-cart** — a 2–4 step chooser ("what are you making?" → recommends a variant/bundle → pre-fills the buy-box or adds to cart) for choice-heavy catalogues. It is an *addition*, never a gate: the direct PDP always works without it.92- **Digital Product Passport panel** — for textiles, the EU ESPR Digital Product Passport (fibre composition, recycled content, care/repair, origin, plus a scannable code) is phasing in later this decade. Render it as a `data-display` Definition panel with a QR/link, populated from **real** data only and honestly stubbed until the passport exists — never fabricated compliance text. Treat it as an optional section here; the legal data model is out of this skill's scope.9394## A11y9596- Swatches in a `<fieldset><legend>` as native radios (arrow-key group nav, `:checked` for free); each label's accessible name is the variant name, never just a colour chip.97- The price + availability region is `aria-live="polite"` so screen-reader users hear the update after selecting a variant; the gallery `alt` describes the *current* variant ("Aran throw in Loden, draped").98- Zoom lightbox is a `role="dialog"`, focus-trapped, Esc-closable, focus returned on close (`ultraweb:overlays`).99- A disabled Add-to-cart (no size chosen) carries a reason, not a dead button; the mobile sticky bar's CTA is the same accessible action, not a focus trap.100101## Anti-patterns102103- Variant select that navigates or refetches — a per-colour `<a href>`, or a `router.push` that re-renders the page. Variant change is a client-side crossfade, no reload.104- Hiding out-of-stock variants instead of disabling them — the shopper can't judge a range they can't see.105- Two filled CTAs (Add to cart + Buy now both filled); "Add to cart" competing with a filled wishlist.106- Price or delta without `tabular-nums`; a hand-formatted currency string instead of `Intl.NumberFormat`.107- Swatches as `<div onClick>` — no radio semantics, no keyboard, no `aria-checked`, no ring.108- Gallery aspect not locked → layout shift on crossfade (CLS); `priority` on the gallery image (deprecated — use `preload`); `loading="lazy"` on the LCP gallery image.109- Emoji as swatch/size markers; a gray-box placeholder for a missing variant photo (`ultraweb:imagery` owns honest placeholders).110- A carousel as the only gallery — no thumbnail affordance, no zoom.111- Mobile sticky bar covering content (no safe-area, no reserved padding).112- Inventing the Grundpreis/MwSt line inline instead of deferring to `pricing`/`gate-content`.113- Reaching for a motion library to fade one image — CSS opacity is enough.114115## Worked example — Loop & Thread, the Aran Lambswool Throw116117Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.118119## Composes with120121Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.