Product detail
A single product page with a thumbnail gallery and a buy panel that updates as you pick options.
Composes Breadcrumbs, Split, Carousel, Image, lightboxGroup, Badge, RadioGroup, Select, Alert, Accordion, and Divider (plus the layout primitives).
Demo
Hyzer Labs Originals
Voyager
$19.99
A workhorse distance driver with a long, controllable flight and a dependable finish. Predictable enough for hyzer lines off the tee, stable enough to hold a headwind.
Description
The Voyager was tuned for players who want one driver that does it all. It holds full flex lines in the woods, big sweeping hyzers in the open, and a flat, late fade that keeps it in the fairway. The rim is comfortable for smaller hands, and the flight stays true as the disc beats in.
Specs
- Diameter: 21.1 cm
- Rim width: 2.2 cm
- Available weights: 165–175 g
- PDGA approved
Shipping & returns
Free standard shipping on orders over $35. Return unthrown discs within 30 days. Exchange field-tested discs within 14 days if the flight is not right for you.
Source
The whole page, verbatim. Every import is a public export, so you can copy it into an app with the theme installed and it renders the same.
<script lang="ts">
/**
* A product detail page composed entirely from the library.
*
* It imports only public exports. The buy panel is live: the plastic
* RadioGroup drives the derived price, the Carousel pages through
* colorways, and "Add to cart" raises a dismissible success Alert. The
* active carousel slide is a lightboxGroup trigger. Click it (or Tab to it
* and press Enter or Space) to open the full-size viewer at that colorway,
* where you can still page through every colorway. Product art is
* generated SVG data URIs so the sample stays self-contained.
*
* Media area: a vertical thumbnail strip beside the main carousel, the
* classic product-page shape. `activeIndex` is bound both ways to
* `Carousel`'s own `index` prop, so a thumb click pages the carousel and
* dragging or paging the carousel moves the active thumb. One shared
* value, no extra wiring. The thumbs are real `<button>`s, and
* `lightboxGroup` never enhances an `<img>` inside an `<a>` or `<button>`,
* so it leaves them out of the gallery on its own. The main slide stays
* the one obvious way to open the viewer.
*/
import {
Breadcrumbs,
Stack,
Cluster,
Split,
Carousel,
Image,
Badge,
Button,
Alert,
RadioGroup,
Select,
Accordion,
Divider,
lightboxGroup
} from '@hyzer-labs/ui';
import type { BreadcrumbItem, FormOption } from '@hyzer-labs/ui/types';
interface Colorway {
id: string;
label: string;
bg: string;
fg: string;
}
const breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', href: '#' },
{ label: 'Shop', href: '#' },
{ label: 'Distance drivers', href: '#' },
{ label: 'Voyager' }
];
const colorways: Colorway[] = [
{ id: 'cosmic', label: 'Cosmic blue', bg: '1e3a8a', fg: '60a5fa' },
{ id: 'ember', label: 'Ember orange', bg: '7c2d12', fg: 'fb923c' },
{ id: 'meadow', label: 'Meadow green', bg: '14532d', fg: '4ade80' }
];
// Generated product art, so this file needs no asset files to run. Swap in
// your own photography wherever discArt is called: imported files, a static
// path, or a CDN URL.
function discArt(colorway: Colorway): string {
return (
`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600' viewBox='0 0 800 600'%3E` +
`%3Crect width='800' height='600' fill='%23${colorway.bg}'/%3E` +
`%3Ccircle cx='400' cy='300' r='200' fill='%23${colorway.fg}'/%3E` +
`%3Ccircle cx='400' cy='300' r='140' fill='none' stroke='%23ffffff' stroke-opacity='0.4' stroke-width='8'/%3E` +
`%3C/svg%3E`
);
}
// Swap in your own photography by pointing PRODUCT_MEDIA at your image
// source, whatever it is: imported files, a static path, or a CDN URL.
// Every render below reads PRODUCT_MEDIA[colorway.id] rather than a raw
// URL, so that swap is a change to one constant and nothing else in this
// sample touches image URLs.
const PRODUCT_MEDIA: Record<string, string> = Object.fromEntries(
colorways.map((c) => [c.id, discArt(c)])
);
const flight = [
{ label: 'Speed', value: '12' },
{ label: 'Glide', value: '5' },
{ label: 'Turn', value: '-1' },
{ label: 'Fade', value: '3' }
];
const plasticPrices: Record<string, number> = { base: 14.99, pro: 17.99, champion: 19.99 };
const plastics: FormOption[] = [
{ value: 'base', label: 'Base — $14.99' },
{ value: 'pro', label: 'Pro — $17.99' },
{ value: 'champion', label: 'Champion — $19.99' }
];
const weights: FormOption[] = ['165', '168', '170', '173', '175'].map((w) => ({
value: w,
label: `${w} g`
}));
let plastic = $state('champion');
let weight = $state('173');
let added = $state(false);
// Bound both ways to Carousel's own `index`. A thumb click sets it, which
// pages the carousel; dragging or paging the carousel sets it back, which
// moves the active thumb. One value, either direction.
let activeIndex = $state(0);
const price = $derived(plasticPrices[plastic]);
const plasticLabel = $derived(
plastics.find((p) => p.value === plastic)?.label.split(' — ')[0] ?? plastic
);
const sections = [
{ id: 'description', title: 'Description' },
{ id: 'specs', title: 'Specs' },
{ id: 'shipping', title: 'Shipping & returns' }
];
</script>
<Stack gap="lg" padding="lg">
<Breadcrumbs items={breadcrumbs} ariaLabel="Shop breadcrumbs" />
<Split fraction="1/2" gap="lg" stackBelow="md">
<!-- lightboxGroup enhances the carousel's own rendered <img> in place, so
there is no separate Lightbox component. Off-screen slides are inert
(native browser behavior, not this attachment), so only the active
slide is clickable and focusable. Every colorway still joins the
shared gallery, so the viewer pages across all of them. The thumb
strip lives inside the same attached container, but each thumb's
<img> sits inside a <button>, and lightboxGroup skips any media with
an interactive ancestor. The thumbs never become second triggers;
they only drive `activeIndex`. -->
<div class="pdp-media" {@attach lightboxGroup({ dialogLabel: 'Voyager colorways' })}>
<div class="pdp-thumbs" role="group" aria-label="Choose a colorway">
{#each colorways as colorway, i (colorway.id)}
<button
type="button"
class="pdp-thumb"
aria-current={i === activeIndex ? 'true' : undefined}
aria-label={colorway.label}
onclick={() => (activeIndex = i)}
>
<img src={PRODUCT_MEDIA[colorway.id]} alt="" />
</button>
{/each}
</div>
<div class="pdp-carousel">
<Carousel
items={colorways}
ariaLabel="Voyager colorways"
loop
bind:index={activeIndex}
slideLabel={(colorway) => colorway.label}
>
{#snippet slide(colorway)}
<Image
src={PRODUCT_MEDIA[colorway.id]}
alt="Voyager in {colorway.label}"
aspectRatio="4/3"
fit="cover"
rounded="md"
/>
{/snippet}
</Carousel>
</div>
</div>
<Stack gap="md">
<Stack gap="xs">
<p class="eyebrow">Hyzer Labs Originals</p>
<h2>Voyager</h2>
<Cluster gap="sm">
{#each flight as f (f.label)}
<Badge variant="outline" size="sm">{f.label} {f.value}</Badge>
{/each}
</Cluster>
</Stack>
<p class="price">${price.toFixed(2)}</p>
<p class="muted">
A workhorse distance driver with a long, controllable flight and a dependable finish.
Predictable enough for hyzer lines off the tee, stable enough to hold a headwind.
</p>
<RadioGroup name="plastic" label="Plastic" options={plastics} bind:value={plastic} />
<Select name="weight" label="Weight" options={weights} bind:value={weight} />
<Cluster gap="sm">
<Button intent="primary" size="lg" onclick={() => (added = true)}>Add to cart</Button>
<Button variant="outline" size="lg">Add to wishlist</Button>
</Cluster>
{#if added}
<Alert
intent="success"
title="Added to cart"
headingLevel={3}
onDismiss={() => (added = false)}
>
Voyager — {plasticLabel}, {weight} g.
</Alert>
{/if}
</Stack>
</Split>
<Divider spacing="sm" />
<Accordion items={sections} type="single" defaultOpen="description" headingLevel={3}>
{#snippet panel(section)}
{#if section.id === 'description'}
<p>
The Voyager was tuned for players who want one driver that does it all. It holds full flex
lines in the woods, big sweeping hyzers in the open, and a flat, late fade that keeps it
in the fairway. The rim is comfortable for smaller hands, and the flight stays true as the
disc beats in.
</p>
{:else if section.id === 'specs'}
<ul>
<li>Diameter: 21.1 cm</li>
<li>Rim width: 2.2 cm</li>
<li>Available weights: 165–175 g</li>
<li>PDGA approved</li>
</ul>
{:else}
<p>
Free standard shipping on orders over $35. Return unthrown discs within 30 days. Exchange
field-tested discs within 14 days if the flight is not right for you.
</p>
{/if}
{/snippet}
</Accordion>
</Stack>
<style>
/* Mobile-first: the main image on top, the thumb strip a horizontal row
below it (column-reverse, since DOM order is [thumbs, carousel], so the
carousel, last in the DOM, paints first). Above the sm width it flips to
the classic product-page shape: a vertical thumb column on the left, the
main image filling the rest on the right. Split's own stackBelow stacks
in DOM order without reversing it, so this direction flip is
hand-rolled. The sm threshold (640px) matches Split's own --hz-width-sm
fallback for consistency. */
.pdp-media {
display: flex;
flex-direction: column-reverse;
gap: 0.75rem;
}
.pdp-thumbs {
display: flex;
flex-direction: row;
gap: 0.5rem;
overflow-x: auto;
}
.pdp-thumb {
flex: 0 0 auto;
width: 3.5rem;
height: 3.5rem;
padding: 0;
border: 2px solid transparent;
border-radius: var(--hz-radius-sm, 0.25rem);
overflow: hidden;
cursor: pointer;
background: none;
}
.pdp-thumb[aria-current='true'] {
border-color: var(--hz-intent-primary, #2563eb);
}
.pdp-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.pdp-carousel {
min-width: 0;
}
@media (min-width: 640px) {
.pdp-media {
flex-direction: row;
align-items: flex-start;
gap: 1rem;
}
.pdp-thumbs {
flex-direction: column;
overflow-x: visible;
}
.pdp-carousel {
flex: 1;
}
}
h2 {
margin: 0;
font-size: var(--hz-font-size-xl, 1.65rem);
font-weight: var(--hz-font-weight-bold, 700);
}
p,
ul {
margin: 0;
}
ul {
padding-inline-start: 1.25rem;
}
.eyebrow {
font-size: var(--hz-font-size-xs, 0.75rem);
font-weight: var(--hz-font-weight-semibold, 600);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--hz-color-text-muted, #6b7280);
}
.price {
font-size: var(--hz-font-size-lg, 1.4rem);
font-weight: var(--hz-font-weight-bold, 700);
}
.muted {
color: var(--hz-color-text-muted, #6b7280);
}
</style>