Product overview block
Product page with a keyboard-selectable thumbnail gallery, SvRating, color swatches, an SvSegmented size picker that disables sold-out sizes, quantity, Add to cart that asks for a size first, and an SvAccordion with Details, Shipping and Returns.
A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.
About this example
A Svelte product page built from the SvGrid UI kit, with a gallery on the left and the details on the right. The gallery has previous and next buttons and a thumbnail strip of native radios, so the arrow keys pick an image. The details show a read-only SvRating, the price, color swatches, an SvSegmented size picker that disables sizes sold out in the chosen color, an SvNumberInput quantity, an SvButton Add to cart that asks for a size first, and an SvAccordion with Details, Shipping and Returns. All state lives in the headless createProductOverview() core.
Product overview block - a product page split in two. Left: a gallery with a main image, previous and next buttons and a thumbnail strip you can pick with the arrow keys. Right: the name, a read-only SvRating, the price, color swatches, an SvSegmented size picker whose sold-out sizes are disabled for the chosen color, an SvNumberInput quantity, an SvButton Add to cart that asks for a size first, an SvAccordion with Details, Shipping and Returns, and the store's delivery and returns promises. Money is integer cents. Pure UI-kit, --sg-* themed.
Headless: createProductOverview() below owns the gallery position, the color, size and quantity, the size validation and the add-to-cart flow with no markup. The component under it is one styled render of that core; import createProductOverview from this file (or npx @svgrid/ui add product-overview-split --headless for the core alone) to draw your own.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
How does the size validation work?
add() checks that a size is chosen before it calls your onAdd option. Without one it sets sizeError to Choose a size., which the block passes to SvSegmented as error and invalid, and the cart request is not sent. Picking a size clears the error.
How are sold-out sizes handled?
Each color lists its soldOut sizes. sizeOptions marks those options disabled for the current color, a hint under the picker names them, and switching to a color where the chosen size is sold out clears the size so the shopper picks again.
What changes on a phone?
Below 760 px the two columns become one: the gallery with its thumbnails comes first and the details follow under it, with tighter padding and a smaller heading, so the page fits 375 px.
Related documentation
Related articles
- Building an E-commerce Product Catalog Grid in Svelte - How to build a product catalog admin grid with image cells, inline price and stock edits, category filtering, and bulk updates using SvGrid.
- What's New in SvGrid Enterprise - Export, Pivot, Import, Print, and AI - A practical look at @svgrid/enterprise - pivot tables, Excel and PDF export, data import, printing, and an AI assistant. Covers when each feature earns its place and what to watch out for in production.
Source code (654-block-product-overview-split.svelte)
<script lang="ts" module>
/**
* Product overview block - a product page split in two. Left: a gallery with
* a main image, previous and next buttons and a thumbnail strip you can pick
* with the arrow keys. Right: the name, a read-only SvRating, the price,
* color swatches, an SvSegmented size picker whose sold-out sizes are
* disabled for the chosen color, an SvNumberInput quantity, an SvButton Add
* to cart that asks for a size first, an SvAccordion with Details, Shipping
* and Returns, and the store's delivery and returns promises. Money is
* integer cents. Pure UI-kit, --sg-* themed.
*
* Headless: createProductOverview() below owns the gallery position, the
* color, size and quantity, the size validation and the add-to-cart flow
* with no markup. The component under it is one styled render of that core;
* import createProductOverview from this file (or `npx @svgrid/ui add
* product-overview-split --headless` for the core alone) to draw your own.
*/
export type OverviewColor = { id: string; name: string; tint: string; soldOut?: string[] }
export type OverviewSize = { value: string; label: string }
export type OverviewView = { id: string; label: string; art: string }
export type OverviewInfo = { id: string; label: string; lines: string[] }
export type OverviewProduct = {
id: string; name: string; summary: string; priceCents: number; rating: number; reviews: number
colors: OverviewColor[]; sizes: OverviewSize[]; views: OverviewView[]; info: OverviewInfo[]
}
export type OverviewSelection = { product: OverviewProduct; color: OverviewColor; size: string; qty: number }
export type ProductOverviewOptions = {
product?: OverviewProduct
maxQty?: number
currency?: string
locale?: string
/** Put the selection in the cart; throw (or reject) and the form stays as it is. */
onAdd?: (selection: OverviewSelection) => Promise<void> | void
onError?: (error: unknown) => void
}
const SAMPLE: OverviewProduct = {
id: 'apr-linen', name: 'Washed linen apron', priceCents: 4800, rating: 4.7, reviews: 86,
summary: 'A full-length cross-back apron in heavy stonewashed linen, with a split front pocket and long ties that wrap to the front.',
colors: [
{ id: 'clay', name: 'Clay', tint: 'var(--sg-danger, #dc2626)', soldOut: ['XL'] },
{ id: 'oat', name: 'Oat', tint: 'var(--sg-warning, #d97706)' },
{ id: 'sage', name: 'Sage', tint: 'var(--sg-success, #16a34a)', soldOut: ['XS', 'S'] },
{ id: 'slate', name: 'Slate', tint: 'var(--sg-muted, #64748b)' },
],
sizes: ['XS', 'S', 'M', 'L', 'XL'].map((s) => ({ value: s, label: s })),
views: [
{ id: 'front', label: 'Front', art: 'front' }, { id: 'back', label: 'Cross-back straps', art: 'back' },
{ id: 'pocket', label: 'Split pocket', art: 'pocket' }, { id: 'folded', label: 'Folded', art: 'folded' },
],
info: [
{ id: 'details', label: 'Details', lines: ['100% European linen, 240 gsm, stonewashed.', 'Length 90 cm from the top of the bib.', 'Machine wash warm. Tumble dry low.'] },
{ id: 'shipping', label: 'Shipping', lines: ['Ships from our Portland studio in 1 to 2 business days.', 'Free standard shipping on orders over $75.'] },
{ id: 'returns', label: 'Returns', lines: ['Return unworn items within 30 days for a full refund.', 'The return label is free and printed from your order page.'] },
],
}
export function createProductOverview(opts: ProductOverviewOptions = {}) {
const product = opts.product ?? SAMPLE
const maxQty = opts.maxQty ?? 10
const money = new Intl.NumberFormat(opts.locale ?? 'en-US', { style: 'currency', currency: opts.currency ?? 'USD' })
let view = $state(0)
let colorId = $state(product.colors[0]?.id ?? '')
let size = $state<string | null>(null)
let qty = $state(1)
let sizeError = $state('')
let busy = $state(false)
const color = $derived(product.colors.find((c) => c.id === colorId) ?? product.colors[0]!)
const soldOut = (s: string) => !!color.soldOut?.includes(s)
/** Sizes with the sold-out ones for the current color disabled. */
const sizeOptions = $derived(product.sizes.map((s) => ({ ...s, disabled: soldOut(s.value) })))
const n = product.views.length
const showView = (i: number) => { view = ((i % n) + n) % n }
function chooseColor(id: string) {
colorId = id
if (size && soldOut(size)) size = null
}
function chooseSize(v: string) {
if (soldOut(v)) return
size = v
sizeError = ''
}
async function add() {
if (busy) return
if (!size) { sizeError = 'Choose a size.'; return }
busy = true
try {
await opts.onAdd?.({ product, color, size, qty })
} catch (e) {
opts.onError?.(e)
} finally {
busy = false
}
}
return {
product, maxQty,
get view() { return view },
get current() { return product.views[view]! },
get color() { return color },
get size() { return size },
get sizeOptions() { return sizeOptions },
get sizeError() { return sizeError },
/** '' or a line naming the sizes sold out in the current color. */
get soldOutNote() { const s = color.soldOut ?? []; return s.length ? `${s.join(' and ')} sold out in ${color.name}.` : '' },
get qty() { return qty },
set qty(v: number) { qty = Math.min(maxQty, Math.max(1, Math.round(v || 1))) },
get busy() { return busy },
showView, chooseColor, chooseSize, add,
next: () => showView(view + 1),
prev: () => showView(view - 1),
format: (cents: number) => money.format(cents / 100),
/** Spread on a native radio <input> per thumbnail; arrow keys move between them. */
thumbProps: (i: number) => ({
type: 'radio' as const, name: `view-${product.id}`, value: product.views[i]!.id, checked: view === i,
'aria-label': `${product.views[i]!.label}, image ${i + 1} of ${n}`, onchange: () => showView(i),
}),
/** Spread on a native radio <input> per color swatch. */
swatchProps: (c: OverviewColor) => ({
type: 'radio' as const, name: `color-${product.id}`, value: c.id, checked: colorId === c.id,
'aria-label': c.name, onchange: () => chooseColor(c.id),
}),
/** Spread on a native radio <input> per size. */
sizeProps: (s: OverviewSize) => ({
type: 'radio' as const, name: `size-${product.id}`, value: s.value, checked: size === s.value,
disabled: soldOut(s.value), 'aria-invalid': sizeError ? ('true' as const) : undefined, onchange: () => chooseSize(s.value),
}),
/** Spread on a plain Add to cart <button>. */
addProps: () => ({ type: 'button' as const, disabled: busy, 'aria-busy': busy, onclick: add }),
}
}
</script>
<script lang="ts">
import { SvRating, SvSegmented, SvNumberInput, SvButton, SvAccordion, SvToaster, toast } from '@svgrid/grid'
const pdp = createProductOverview({
// Replace with your cart request; a thrown error keeps the form as it is.
onAdd: ({ product, color, size, qty }) =>
toast.promise(new Promise<void>((res) => setTimeout(res, 900)), {
loading: 'Adding to your cart...',
success: `${qty} x ${product.name}, ${color.name}, size ${size}, added to your cart`,
error: 'Could not add it. Try again.',
}),
})
let open = $state(['details'])
const linesOf = (id: string) => pdp.product.info.find((x) => x.id === id)?.lines ?? []
</script>
<main class="sv-pdp">
<section class="sv-pdp-gallery" aria-label="Product images">
<figure class="sv-pdp-main" style:--sv-pdp-tint={pdp.color.tint}>
{@render apron(pdp.current.art)}
<button type="button" class="sv-pdp-nav sv-pdp-nav-prev" aria-label="Previous image" onclick={pdp.prev}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3L5 8l5 5" /></svg>
</button>
<button type="button" class="sv-pdp-nav sv-pdp-nav-next" aria-label="Next image" onclick={pdp.next}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5" /></svg>
</button>
<figcaption class="sv-pdp-caption">{pdp.current.label}, {pdp.view + 1} of {pdp.product.views.length}</figcaption>
</figure>
<div class="sv-pdp-thumbs" role="radiogroup" aria-label="Choose an image">
{#each pdp.product.views as v, i (v.id)}
<label class="sv-pdp-thumb" style:--sv-pdp-tint={pdp.color.tint}>
<input {...pdp.thumbProps(i)} />
{@render apron(v.art)}
</label>
{/each}
</div>
</section>
<section class="sv-pdp-details" aria-labelledby="sv-pdp-title">
<h1 id="sv-pdp-title">{pdp.product.name}</h1>
<div class="sv-pdp-rating">
<SvRating value={pdp.product.rating} allowHalf readonly size="sm" ariaLabel={`Rated ${pdp.product.rating} out of 5`} />
<span class="sv-pdp-muted">{pdp.product.rating.toFixed(1)} ({pdp.product.reviews} reviews)</span>
</div>
<p class="sv-pdp-price">{pdp.format(pdp.product.priceCents)}</p>
<p class="sv-pdp-summary">{pdp.product.summary}</p>
<fieldset class="sv-pdp-colors">
<legend>Color: <span class="sv-pdp-muted">{pdp.color.name}</span></legend>
<div class="sv-pdp-swatches">
{#each pdp.product.colors as c (c.id)}
<label class="sv-pdp-swatch" style:--sv-pdp-tint={c.tint}><input {...pdp.swatchProps(c)} /></label>
{/each}
</div>
</fieldset>
<SvSegmented label="Size" options={pdp.sizeOptions} value={pdp.size} onChange={(v) => pdp.chooseSize(String(v))}
block required hint={pdp.soldOutNote || undefined} error={pdp.sizeError} invalid={!!pdp.sizeError} />
<div class="sv-pdp-buy">
<SvNumberInput label="Quantity" value={pdp.qty} min={1} max={pdp.maxQty} width={112} onChange={(v) => (pdp.qty = v ?? 1)} />
<div class="sv-pdp-add">
<SvButton variant="primary" block loading={pdp.busy} onclick={pdp.add}>Add to cart</SvButton>
</div>
</div>
<ul class="sv-pdp-trust">
<li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /></svg>Free returns within 30 days</li>
<li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h11v9H3zM14 10h4l3 3v3h-7" /><circle cx="7" cy="18" r="1.8" /><circle cx="17" cy="18" r="1.8" /></svg>Ships in 1 to 2 business days</li>
<li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" /><path d="M9 12l2 2 4-4" /></svg>Pre-washed, so it will not shrink</li>
</ul>
<SvAccordion items={pdp.product.info} expandMode="multiple" bind:expanded={open}>
{#snippet panel(item)}
<ul class="sv-pdp-info">
{#each linesOf(item.id) as line (line)}<li>{line}</li>{/each}
</ul>
{/snippet}
</SvAccordion>
</section>
</main>
{#snippet apron(art: string)}
<svg viewBox="0 0 48 48" aria-hidden="true">
{#if art === 'front'}
<path class="sv-pdp-fill" d="M19 10h10l2 9h5l-1 19a4 4 0 0 1-4 4H17a4 4 0 0 1-4-4l-1-19h5z" /><path d="M19 10c0-6 10-6 10 0" /><path d="M12 19l-6 4M36 19l6 4" /><path d="M17 27h14v7H17zM24 27v7" />
{:else if art === 'back'}
<path class="sv-pdp-fill" d="M12 20h24l-1 18a4 4 0 0 1-4 4H17a4 4 0 0 1-4-4z" /><path d="M14 20L30 6M34 20L18 6" /><path d="M12 20l-6 5M36 20l6 5" />
{:else if art === 'pocket'}
<path class="sv-pdp-fill" d="M8 14h32v22a3 3 0 0 1-3 3H11a3 3 0 0 1-3-3z" /><path d="M24 14v25" /><path d="M11 17h26" stroke-dasharray="2 2" /><path d="M30 14l2-8 2.5.8-1.5 7.2" />
{:else}
<path class="sv-pdp-fill" d="M8 20h32v9H8z" /><path class="sv-pdp-fill" d="M10 29h28v11H10z" /><path d="M8 24c-4 0-4 6 0 7M40 24c4 0 4 6 0 7" />
{/if}
</svg>
{/snippet}
<SvToaster position="bottom-right" />
<style>
.sv-pdp {
display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 40px; align-items: start;
width: 100%; box-sizing: border-box; min-height: 560px; padding: 32px 28px;
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font-family: var(--sg-font, inherit);
}
.sv-pdp-gallery { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.sv-pdp-main, .sv-pdp-thumb {
display: grid; place-items: center; border-radius: var(--sg-radius-lg, 10px);
background: color-mix(in srgb, var(--sv-pdp-tint) 14%, var(--sg-bg, #fff));
color: color-mix(in srgb, var(--sv-pdp-tint) 70%, var(--sg-fg, #0f172a));
transition: background .2s, color .2s;
}
.sv-pdp-main { position: relative; margin: 0; aspect-ratio: 1; }
.sv-pdp svg { display: block; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
.sv-pdp-main > svg { width: 52%; height: 52%; stroke-width: 1.6; }
.sv-pdp-fill { fill: color-mix(in srgb, currentColor 18%, transparent); }
.sv-pdp-caption { position: absolute; left: 14px; bottom: 12px; font-size: 12.5px; color: var(--sg-muted, #64748b); }
.sv-pdp-nav {
position: absolute; top: 50%; translate: 0 -50%; display: grid; place-items: center; width: 36px; height: 36px; padding: 0;
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 999px; cursor: pointer;
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
}
.sv-pdp-nav-prev { left: 12px; }
.sv-pdp-nav-next { right: 12px; }
.sv-pdp-nav svg { width: 16px; height: 16px; stroke-width: 1.8; }
.sv-pdp-nav:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; }
.sv-pdp-thumbs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.sv-pdp-thumb { position: relative; aspect-ratio: 1; cursor: pointer; box-shadow: inset 0 0 0 1px transparent; }
.sv-pdp-thumb svg { width: 56%; height: 56%; stroke-width: 2.2; }
.sv-pdp-thumb input, .sv-pdp-swatch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.sv-pdp-thumb:has(input:checked) { box-shadow: inset 0 0 0 2px var(--sg-fg, #0f172a); }
.sv-pdp-thumb:has(input:focus-visible), .sv-pdp-swatch:has(input:focus-visible) { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; }
.sv-pdp-details { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.sv-pdp h1 { margin: 0; font-size: 26px; font-weight: 650; letter-spacing: -.015em; }
.sv-pdp-rating { display: flex; align-items: center; gap: 8px; margin-top: -8px; }
.sv-pdp-muted { color: var(--sg-muted, #64748b); font-size: 13px; font-weight: 400; }
.sv-pdp-price { margin: 0; font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sv-pdp-summary { margin: 0; font-size: 14px; line-height: 1.55; color: var(--sg-muted, #64748b); }
.sv-pdp-colors { margin: 0; padding: 0; border: 0; min-width: 0; }
.sv-pdp-colors legend { padding: 0; margin-bottom: 10px; font-size: 13px; font-weight: 600; }
.sv-pdp-swatches { display: flex; gap: 12px; }
.sv-pdp-swatch {
position: relative; width: 28px; height: 28px; border-radius: 999px; cursor: pointer;
background: color-mix(in srgb, var(--sv-pdp-tint) 75%, var(--sg-bg, #fff));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sg-fg, #0f172a) 15%, transparent);
}
.sv-pdp-swatch:has(input:checked) { outline: 2px solid var(--sg-fg, #0f172a); outline-offset: 3px; }
.sv-pdp-buy { display: flex; align-items: flex-end; gap: 12px; }
.sv-pdp-add { flex: 1; min-width: 0; }
.sv-pdp-trust { list-style: none; margin: 0; padding: 14px 0; display: flex; flex-direction: column; gap: 8px; border-block: 1px solid var(--sg-border, #e2e8f0); font-size: 13.5px; }
.sv-pdp-trust li { display: flex; align-items: center; gap: 10px; }
.sv-pdp-trust svg { width: 18px; height: 18px; flex: none; stroke-width: 1.7; color: var(--sg-muted, #64748b); }
.sv-pdp-info { list-style: disc; margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; font-size: 13.5px; line-height: 1.5; color: var(--sg-muted, #64748b); }
@media (max-width: 760px) {
.sv-pdp { grid-template-columns: 1fr; gap: 24px; padding: 20px 16px; }
.sv-pdp-gallery { position: static; }
.sv-pdp h1 { font-size: 22px; }
}
</style>More Blocks examples
- Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
- Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
- Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
- Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
- App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.