Product detail split
Gallery on one side, the buying decision on the other, in sequence.
Live preview
Source
This exact file renders the preview above.
import { Check, Truck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Divider } from '@/components/ui/layout'
import { Select } from '@/components/ui/select'
import { Field } from '@/components/ui/field'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
/**
* Product detail split
*
* Gallery on one side, buying decision on the other. The buy column is ordered
* by what a customer needs in sequence: name, price, size, add, delivery — not
* by what marketing wants to say first.
*/
export default function ProductDetailSplit() {
const product = products[0]
if (!product) return null
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
<div>
<div
className="grid-paper aspect-square w-full rounded-xl border border-line bg-surface-sunken"
aria-hidden="true"
/>
<div className="mt-3 grid grid-cols-4 gap-3">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className="grid-paper aspect-square rounded-md border border-line bg-surface-sunken"
aria-hidden="true"
/>
))}
</div>
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge>{product.category}</Badge>
{product.badge ? <Badge tone="accent">{product.badge}</Badge> : null}
</div>
<h2 className="display-type mt-4 text-2xl font-semibold text-ink-strong sm:text-3xl">
{product.name}
</h2>
<p className="mt-3 flex items-baseline gap-3">
<span className="display-type text-2xl font-semibold text-ink-strong tabular-nums">
{formatCurrency(product.priceCents)}
</span>
{product.compareAtCents ? (
<span className="font-mono text-sm text-ink-subtle line-through tabular-nums">
{formatCurrency(product.compareAtCents)}
</span>
) : null}
</p>
<p className="mt-4 text-md leading-relaxed text-ink-muted">{product.description}</p>
<Divider className="my-6" weight="subtle" />
<div className="flex flex-col gap-4">
<Field name="size" label="Size" required>
{(field) => (
<Select
{...field}
defaultValue="m"
options={[
{ value: 'xs', label: 'XS' },
{ value: 's', label: 'S' },
{ value: 'm', label: 'M' },
{ value: 'l', label: 'L' },
{ value: 'xl', label: 'XL — out of stock', disabled: true },
]}
/>
)}
</Field>
<div className="flex flex-wrap gap-3">
<Button size="lg" className="flex-1">
Add to bag
</Button>
<Button size="lg" variant="outline">
Save
</Button>
</div>
</div>
<ul className="mt-6 flex flex-col gap-2.5 border-t border-line-subtle pt-6">
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Truck className="size-4 shrink-0 text-accent" aria-hidden="true" />
Free delivery over $150 — 2 to 4 working days
</li>
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
60-day returns, unworn, with tags
</li>
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{product.material}
</li>
</ul>
<p className="mt-5 text-xs text-ink-subtle">
Fictional product. Nothing can be purchased in this demo.
</p>
</div>
</div>
</Container>
</section>
)
}
components/blocks/sections/product/detail-split.tsx
import { Check, Truck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Divider } from '@/components/ui/layout'
import { Select } from '@/components/ui/select'
import { Field } from '@/components/ui/field'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
/**
* Product detail split
*
* Gallery on one side, buying decision on the other. The buy column is ordered
* by what a customer needs in sequence: name, price, size, add, delivery — not
* by what marketing wants to say first.
*/
export default function ProductDetailSplit() {
const product = products[0]
if (!product) return null
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
<div>
<div
className="grid-paper aspect-square w-full rounded-xl border border-line bg-surface-sunken"
aria-hidden="true"
/>
<div className="mt-3 grid grid-cols-4 gap-3">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className="grid-paper aspect-square rounded-md border border-line bg-surface-sunken"
aria-hidden="true"
/>
))}
</div>
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge>{product.category}</Badge>
{product.badge ? <Badge tone="accent">{product.badge}</Badge> : null}
</div>
<h2 className="display-type mt-4 text-2xl font-semibold text-ink-strong sm:text-3xl">
{product.name}
</h2>
<p className="mt-3 flex items-baseline gap-3">
<span className="display-type text-2xl font-semibold text-ink-strong tabular-nums">
{formatCurrency(product.priceCents)}
</span>
{product.compareAtCents ? (
<span className="font-mono text-sm text-ink-subtle line-through tabular-nums">
{formatCurrency(product.compareAtCents)}
</span>
) : null}
</p>
<p className="mt-4 text-md leading-relaxed text-ink-muted">{product.description}</p>
<Divider className="my-6" weight="subtle" />
<div className="flex flex-col gap-4">
<Field name="size" label="Size" required>
{(field) => (
<Select
{...field}
defaultValue="m"
options={[
{ value: 'xs', label: 'XS' },
{ value: 's', label: 'S' },
{ value: 'm', label: 'M' },
{ value: 'l', label: 'L' },
{ value: 'xl', label: 'XL — out of stock', disabled: true },
]}
/>
)}
</Field>
<div className="flex flex-wrap gap-3">
<Button size="lg" className="flex-1">
Add to bag
</Button>
<Button size="lg" variant="outline">
Save
</Button>
</div>
</div>
<ul className="mt-6 flex flex-col gap-2.5 border-t border-line-subtle pt-6">
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Truck className="size-4 shrink-0 text-accent" aria-hidden="true" />
Free delivery over $150 — 2 to 4 working days
</li>
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
60-day returns, unworn, with tags
</li>
<li className="flex items-center gap-2.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{product.material}
</li>
</ul>
<p className="mt-5 text-xs text-ink-subtle">
Fictional product. Nothing can be purchased in this demo.
</p>
</div>
</div>
</Container>
</section>
)
}
components/ui/select.tsx
import type { SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'
/**
* Select
*
* A styled native `<select>`, on purpose. The native control brings free
* keyboard support, free mobile pickers and free assistive-tech semantics; the
* only thing Foundry adds is the chevron and the shared control chrome.
*
* When the interaction genuinely needs filtering or multi-select, reach for the
* Combobox instead — see `/components/combobox`.
*/
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[]
/** Rendered as a disabled, selected-by-default first option. */
placeholder?: string
selectSize?: 'sm' | 'md' | 'lg'
/** Groups options under `<optgroup>` labels. */
groups?: Array<{ label: string; options: SelectOption[] }>
}
const heights = {
sm: 'h-control-sm text-xs',
md: 'h-control text-sm',
lg: 'h-control-lg text-base',
} as const
export function Select({
options,
groups,
placeholder,
selectSize = 'md',
className,
...props
}: SelectProps) {
return (
<div className="relative flex items-center">
<select
className={cn(
controlSurface,
heights[selectSize],
'cursor-pointer appearance-none py-0 pr-9 pl-3',
className,
)}
defaultValue={props.value === undefined && placeholder ? '' : undefined}
{...props}
>
{placeholder ? (
<option value="" disabled>
{placeholder}
</option>
) : null}
{groups
? groups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</optgroup>
))
: options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-3 size-4 text-ink-subtle"
aria-hidden="true"
/>
</div>
)
}
lib/format.ts
/** Small formatting helpers shared across catalogue and starter surfaces. */
export function titleCase(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
})
export function formatCurrency(cents: number): string {
return currencyFormatter.format(cents / 100)
}
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
export function formatCompact(value: number): string {
return compactFormatter.format(value)
}
/**
* Dates in Foundry are authored as ISO date strings so that server and client
* renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
* same reason — no hydration drift from the visitor's locale or timezone.
*/
export function formatDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function formatShortDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}
export function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The buy column is ordered by what a customer needs in sequence — name, price, size, add, delivery — not by what marketing wants to say first.
- Disable unavailable variants rather than removing them; the customer needs to know their size exists.
- Put delivery and returns directly under the button, where the hesitation happens.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Gallery with thumbnails
- Size select
- Reassurance list
Accessibility
- Labelled variant
- Size is a labelled Field wrapping a native select.
- Disabled options
- Unavailable sizes stay in the list, marked disabled with a reason.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All sectionsProduct grid
A storefront grid with reserved image areas, price comparison and stock state.
starterFeatured3 variantsCheckout wizard
Four steps with the order summary repeated on every one.
advancedFeatured5 variantsEcommerce starter
A storefront with ten product routes, five category routes, a working cart with quantity controls and the multi-step checkout from the form catalogue.
advancedFeatured4 variants