Variant cards
Variant selector as image cards with per-serving pricing and a best value badge. Arrow key radiogroup.
componentcardssupplementswellnessvariants
$
npx shadcn@latest add @storekit/pdp-variant-cardspdp-variant-cards
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-variant-cards$
pnpm dlx shadcn@latest add @storekit/pdp-variant-cards$
yarn dlx shadcn@latest add @storekit/pdp-variant-cards$
bunx --bun shadcn@latest add @storekit/pdp-variant-cardsSource
components/storekit/pdp/pdp-variant-cards.tsx
124 lines
"use client"
import { useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Badge } from "@/components/storekit/ui/badge"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Variant selector as cards: thumb, label, price and a per-serving price so
the value of the larger size is obvious. A real radiogroup with arrow keys. */
type VariantCard = { id: string; label: string; image: string; price: number; servings: number; badge?: string; 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 DEFAULT_OPTIONS: VariantCard[] = [
{ id: "30", label: "30 servings", image: "/brands/morrow/greens.webp", price: 4900, servings: 30 },
{ id: "60", label: "60 servings", image: "/brands/morrow/greens.webp", price: 8900, servings: 60, badge: "Best value" },
{ id: "bundle", label: "3-pouch bundle", image: "/brands/morrow/greens.webp", price: 12900, servings: 90 },
]
export function PdpVariantCards({
options = DEFAULT_OPTIONS,
defaultValue,
onChange,
label = "Choose a size",
className,
}: {
options?: VariantCard[]
defaultValue?: string
onChange?: (id: string) => void
label?: string
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("pdp", "pdp-variant-cards")
const [value, setValue] = useState(defaultValue ?? options.find((o) => !o.soldOut)?.id ?? options[0]?.id)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const current = Math.max(0, options.findIndex((o) => o.id === value))
const select = (i: number, focus = false) => {
const o = options[i]
if (!o || o.soldOut) return
if (o.id !== value) sk.emit("interact", "variant", undefined)
setValue(o.id)
onChange?.(o.id)
if (focus) refs.current[i]?.focus()
}
const onKeyDown = (e: React.KeyboardEvent) => {
const forward = e.key === "ArrowRight" || e.key === "ArrowDown"
const back = e.key === "ArrowLeft" || e.key === "ArrowUp"
if (!forward && !back) return
e.preventDefault()
const n = options.length
for (let s = 1; s <= n; s++) {
const i = (current + (forward ? s : -s) + n * 2) % n
if (!options[i].soldOut) return select(i, true)
}
}
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
{label}
</span>
<div role="radiogroup" aria-label={label} className="grid gap-3 sm:grid-cols-3">
{options.map((o, i) => {
const selected = o.id === value
return (
<button
key={o.id}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
aria-disabled={o.soldOut || undefined}
tabIndex={selected ? 0 : -1}
onClick={() => select(i)}
onKeyDown={onKeyDown}
className={cn(
"relative flex items-center gap-3 rounded-sk-card border bg-sk-card p-3 text-left transition-[border-color,background-color] duration-200 ease-sk-out active:scale-[0.97] sm:flex-col sm:items-stretch sm:p-4",
selected ? "border-sk-accent bg-sk-accent/[0.05]" : "border-sk-line hover:border-sk-ink/30",
o.soldOut && "cursor-not-allowed opacity-50 active:scale-100",
RING
)}
>
{o.badge ? (
<Badge tone="accent" className="absolute -top-2.5 right-3">
{o.badge}
</Badge>
) : null}
<span className="relative h-16 w-16 shrink-0 overflow-hidden rounded-sk-btn bg-sk-surface sm:aspect-square sm:h-auto sm:w-full">
<img src={o.image} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
</span>
<span className="flex min-w-0 flex-1 items-center gap-3 sm:items-start">
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium text-sk-ink">{o.label}</span>
<span className="text-sm font-semibold tabular-nums text-sk-price">{o.soldOut ? "Sold out" : money(o.price)}</span>
<span className="font-sk-mono text-[11px] tabular-nums text-sk-muted">{money(Math.round(o.price / o.servings))} / serving</span>
</span>
<span
aria-hidden
className={cn(
"grid h-5 w-5 shrink-0 place-items-center rounded-full border transition-colors duration-200",
selected ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card"
)}
>
<motion.span initial={false} animate={{ scale: selected ? 1 : 0, opacity: selected ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2 }}>
<Check size={12} strokeWidth={3} />
</motion.span>
</span>
</span>
</button>
)
})}
</div>
</div>
)
}
Usage
Import from "@/components/storekit/pdp/pdp-variant-cards" and pass your product data. The demo above exercises the full prop surface with sample values.