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-photopdp-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-photoSource
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.