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-cards
pdp-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-cards

Source

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.

Variant cards, StoreKit