Photo swatches

Photographic finish or format swatches with the selected name, detail and price under the row.

componentswatchanyvariants
$npx shadcn@latest add @storekit/pdp-swatch-photo
pdp-swatch-photo

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/pdp-swatch-photo
$pnpm dlx shadcn@latest add @storekit/pdp-swatch-photo
$yarn dlx shadcn@latest add @storekit/pdp-swatch-photo
$bunx --bun shadcn@latest add @storekit/pdp-swatch-photo

Source

components/storekit/pdp/pdp-swatch-photo.tsx
141 lines
"use client"

import { useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Photographic swatches. Each option is a small photo of the actual finish
   or format, so a pattern or texture reads before the shopper commits. The
   selected name sits under the row with an optional detail line and price. */

type PhotoSwatch = { id: string; name: string; image: string; detail?: string; price?: number; soldOut?: boolean }

const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const EASE = [0.16, 1, 0.3, 1] as const

const DEFAULT_SWATCHES: PhotoSwatch[] = [
  { id: "pouch", name: "Pouch", image: "/brands/morrow/greens.webp", detail: "30 servings, scoop inside", price: 4900 },
  { id: "sticks", name: "Travel sticks", image: "/brands/morrow/electrolytes.webp", detail: "30 single-serve sticks", price: 5400 },
  { id: "bundle", name: "3-pouch bundle", image: "/brands/morrow/hero.webp", detail: "90 servings, ships together", price: 12900, soldOut: true },
]

export function PdpSwatchPhoto({
  swatches = DEFAULT_SWATCHES,
  label = "Format",
  defaultValue,
  onChange,
  className,
}: {
  swatches?: PhotoSwatch[]
  label?: string
  defaultValue?: string
  onChange?: (id: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const uid = useId()
  const sk = useSkBlock("pdp", "pdp-swatch-photo")
  const [value, setValue] = useState(defaultValue ?? swatches.find((s) => !s.soldOut)?.id ?? swatches[0]?.id)
  const refs = useRef<(HTMLButtonElement | null)[]>([])
  const current = Math.max(0, swatches.findIndex((s) => s.id === value))
  const selected = swatches[current]

  const select = (i: number, focus = false) => {
    const s = swatches[i]
    if (!s) return
    if (s.id !== value) sk.emit("interact", "swatch", undefined)
    setValue(s.id)
    onChange?.(s.id)
    if (focus) refs.current[i]?.focus()
  }

  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = swatches.length
    let next = -1
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (current + 1) % n
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (current - 1 + n) % n
    else if (e.key === "Home") next = 0
    else if (e.key === "End") next = n - 1
    if (next < 0) return
    e.preventDefault()
    select(next, true)
  }

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
      <span id={`${uid}-label`} className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
        {label}
      </span>
      <div role="radiogroup" aria-labelledby={`${uid}-label`} className="flex flex-wrap gap-2.5">
        {swatches.map((s, i) => {
          const isSel = s.id === value
          return (
            <button
              key={s.id}
              ref={(el) => {
                refs.current[i] = el
              }}
              type="button"
              role="radio"
              aria-checked={isSel}
              aria-label={s.soldOut ? `${s.name}, sold out` : s.name}
              tabIndex={isSel ? 0 : -1}
              onClick={() => select(i)}
              onKeyDown={onKeyDown}
              className={cn(
                "group/ph relative h-16 w-16 rounded-sk-btn p-1 transition-transform duration-200 ease-sk-out active:scale-[0.97] sm:h-[72px] sm:w-[72px]",
                RING
              )}
            >
              {isSel ? (
                <motion.span
                  layoutId={`${uid}-ring`}
                  aria-hidden
                  transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
                  className="absolute inset-0 rounded-sk-btn border-2 border-sk-ink"
                />
              ) : (
                <span aria-hidden className="absolute inset-0 rounded-sk-btn border border-sk-line transition-colors duration-200 group-hover/ph:border-sk-ink/40" />
              )}
              <span className="relative block h-full w-full overflow-hidden rounded-[calc(var(--sk-radius-btn)*0.7)] bg-sk-surface">
                <img
                  src={s.image}
                  alt=""
                  loading="lazy"
                  className={cn("absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-sk-out group-hover/ph:scale-[1.06] motion-reduce:transition-none", s.soldOut && "opacity-40")}
                />
                {s.soldOut ? (
                  <span aria-hidden className="absolute left-1/2 top-1/2 h-[1.5px] w-[150%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-ink/60" />
                ) : null}
              </span>
            </button>
          )
        })}
      </div>
      <div className="relative min-h-[44px]" aria-live="polite">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.div
            key={selected?.id}
            initial={{ opacity: 0, y: reduce ? 0 : 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduce ? 0 : -6 }}
            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
            className="flex items-start justify-between gap-4"
          >
            <span className="flex min-w-0 flex-col">
              <span className="truncate text-sm font-medium text-sk-ink">{selected?.name}</span>
              {selected?.detail || selected?.soldOut ? (
                <span className="truncate text-[13px] text-sk-muted">{selected?.soldOut ? "Sold out" : selected?.detail}</span>
              ) : null}
            </span>
            {typeof selected?.price === "number" ? (
              <span className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{money(selected.price)}</span>
            ) : null}
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/pdp/pdp-swatch-photo" and pass your product data. The demo above exercises the full prop surface with sample values.

Photo swatches, StoreKit