Skip to content

Product detail split

Gallery on one side, the buying decision on the other, in sequence.

CommerceintermediateFeaturedproductdetailpdpbuy

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.