Quick-add product card

Size chips over the image swap the price with a crossfade; stepper and Add in the footer; stock badge.

componentquickaddsupplementswellnessatcvariantsquantitystock
$npx shadcn@latest add @storekit/product-card-quickadd
product-card-quickadd

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/product-card-quickadd
$pnpm dlx shadcn@latest add @storekit/product-card-quickadd
$yarn dlx shadcn@latest add @storekit/product-card-quickadd
$bunx --bun shadcn@latest add @storekit/product-card-quickadd

Source

components/storekit/product-card/product-card-quickadd.tsx
144 lines
"use client"

import { useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Badge } from "@/components/storekit/ui/badge"
import { Button } from "@/components/storekit/ui/button"
import { Price } from "@/components/storekit/ui/price"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useAddState } from "@/components/storekit/product-card/product-card-minimal"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Quick-add tile. Size chips rise over the image on hover (always shown on
   touch widths); picking a size swaps the price with a short crossfade. The
   footer holds a quantity stepper and Add. One stock badge, top-left. */

const EASE = [0.23, 1, 0.32, 1] as const

type Dose = { label: string; price: number; compareAt?: number }

export function ProductCardQuickadd({
  title = "Daily Greens",
  image = "/placeholder.webp",
  href = "#",
  doses = [
    { label: "15 servings", price: 2900 },
    { label: "30 servings", price: 4900 },
    { label: "60 servings", price: 8900 },
  ],
  defaultDose = 1,
  stock = "in",
  onAdd,
  className,
}: {
  title?: string
  image?: string
  href?: string
  doses?: Dose[]
  defaultDose?: number
  stock?: "in" | "low" | "out"
  onAdd?: (line: { title: string; dose: string; quantity: number; price: number }) => void | Promise<void>
  className?: string
}) {
  const reduce = useReducedMotion()
  const [doseIndex, setDoseIndex] = useState(Math.min(defaultDose, doses.length - 1))
  const [qty, setQty] = useState(1)
  const dose = doses[doseIndex]
  const sk = useSkBlock("product-card", "product-card-quickadd")
  const { state, add } = useAddState(() => {
    sk.emit("convert", "add_to_cart", dose.price * qty)
    return onAdd?.({ title, dose: dose.label, quantity: qty, price: dose.price })
  })
  const soldOut = stock === "out"

  return (
    <article ref={sk.ref} {...sk.attrs} className={cn("group/img relative flex flex-col gap-3", className)}>
      <div className="relative">
        <ProductImage src={image} alt="" fit="cover" className="rounded-sk-card" />
        <div className="absolute left-3 top-3 overflow-hidden rounded-sk-chip bg-sk-card">
          {stock === "in" ? (
            <Badge tone="success" dot>In stock</Badge>
          ) : stock === "low" ? (
            <Badge tone="sale">Low stock</Badge>
          ) : (
            <Badge tone="outline">Sold out</Badge>
          )}
        </div>
        <div
          role="radiogroup"
          aria-label="Dose"
          className={cn(
            "absolute inset-x-3 bottom-3 z-10 flex gap-1.5",
            "transition-[opacity,transform] duration-200 ease-sk-out",
            "lg:translate-y-1 lg:opacity-0 lg:group-hover/img:translate-y-0 lg:group-hover/img:opacity-100 lg:group-focus-within/img:translate-y-0 lg:group-focus-within/img:opacity-100",
            "motion-reduce:translate-y-0"
          )}
        >
          {doses.map((d, i) => {
            const active = i === doseIndex
            return (
              <button
                key={d.label}
                type="button"
                role="radio"
                aria-checked={active}
                onClick={() => setDoseIndex(i)}
                className={cn(
                  "h-11 flex-1 rounded-sk-chip border font-sk-mono text-[11px] uppercase tracking-[0.08em] outline-none transition-[transform,background-color,color] duration-200 ease-sk-out active:scale-[0.97] lg:h-9",
                  "focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
                  active ? "border-sk-ink bg-sk-ink text-sk-paper" : "border-sk-line bg-sk-card text-sk-ink hover:border-sk-ink/40"
                )}
              >
                {d.label}
              </button>
            )
          })}
        </div>
      </div>

      <div className="flex items-baseline justify-between gap-3">
        <h3 className="min-w-0 truncate text-[15px] font-medium text-sk-ink">
          <a
            href={href}
            className="outline-none after:absolute after:inset-0 after:rounded-sk-card after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-sk-accent focus-visible:after:ring-offset-2 focus-visible:after:ring-offset-sk-paper"
          >
            {title}
          </a>
          <span className="ml-2 font-sk-mono text-[11px] uppercase tracking-[0.08em] text-sk-muted">{dose.label}</span>
        </h3>
        <span className="relative inline-grid overflow-hidden" aria-live="polite">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={dose.label}
              initial={{ opacity: 0, y: reduce ? 0 : 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduce ? 0 : -6 }}
              transition={{ duration: 0.25, ease: EASE }}
              className="col-start-1 row-start-1 justify-self-end"
            >
              <Price amount={dose.price} compareAt={dose.compareAt} size="sm" />
            </motion.span>
          </AnimatePresence>
        </span>
      </div>

      <div className="relative z-10 flex items-center gap-2">
        <Stepper value={qty} onChange={setQty} size="sm" className="h-11 lg:h-9" max={soldOut ? 1 : 20} />
        <Button
          size="sm"
          className="h-11 flex-1 active:scale-[0.97] lg:h-9"
          disabled={soldOut}
          loading={state === "loading"}
          onClick={add}
          aria-label={`Add ${qty} ${title} ${dose.label} to cart`}
        >
          {soldOut ? "Sold out" : state === "added" ? (<><Check size={14} aria-hidden />Added</>) : "Add"}
        </Button>
      </div>
    </article>
  )
}

Usage

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

Quick-add product card, StoreKit