Free sample chooser

Grid of free samples; choose up to two, the rest dim at the limit and say why.

componentgridanysamplesgift
$npx shadcn@latest add @storekit/cart-samples
cart-samples

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

Source

components/storekit/cart/cart-samples.tsx
103 lines
"use client"

import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Free sample chooser. A grid of sample tiles, pick up to `max`. Once the
   limit is reached the rest dim and say why, and deselecting frees a slot.
   The count is announced as it changes. */

export type CartSample = { id: string; title: string; detail?: string; image: string }

const SAMPLE: CartSample[] = [
  { id: "greens-stick", title: "Daily Greens", detail: "1 stick", image: "/brands/morrow/greens.webp" },
  { id: "protein-stick", title: "Protein", detail: "1 serving", image: "/brands/morrow/protein.webp" },
  { id: "focus-trial", title: "Focus", detail: "2 capsules", image: "/brands/morrow/focus.webp" },
  { id: "magnesium-trial", title: "Magnesium Night", detail: "3 gummies", image: "/brands/morrow/magnesium.webp" },
]

export function CartSamples({
  samples = SAMPLE,
  max = 2,
  defaultSelected = [],
  onChange,
  className,
}: {
  samples?: CartSample[]
  max?: number
  defaultSelected?: string[]
  onChange?: (ids: string[]) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-samples")
  const [picked, setPicked] = React.useState<string[]>(defaultSelected.slice(0, max))
  const full = picked.length >= max
  const titleId = React.useId()

  const toggle = (id: string) => {
    const on = picked.includes(id)
    if (!on && full) return
    const next = on ? picked.filter((x) => x !== id) : [...picked, id]
    setPicked(next)
    onChange?.(next)
    sk.emit("interact", on ? "sample_remove" : "sample_add")
  }

  return (
    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} aria-labelledby={titleId} className={cn("w-full", className)}>
      <div className="flex items-baseline justify-between gap-3">
        <h3 id={titleId} className="text-sm font-medium text-sk-ink">
          Choose <span className="tabular-nums">{max}</span> free samples
        </h3>
        <p className={cn("shrink-0 whitespace-nowrap text-[13px] tabular-nums", full ? "text-sk-savings" : "text-sk-muted")} aria-live="polite">
          {picked.length} of {max} chosen
        </p>
      </div>
      <ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3">
        {samples.map((s) => {
          const on = picked.includes(s.id)
          const locked = !on && full
          return (
            <li key={s.id} className="min-w-0">
              <button
                type="button"
                aria-pressed={on}
                aria-disabled={locked}
                onClick={() => toggle(s.id)}
                title={locked ? `You can choose ${max}. Remove one to swap.` : undefined}
                className={cn(
                  "relative flex w-full flex-col rounded-sk-card border p-2 text-left transition-[border-color,background-color,opacity,transform] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper",
                  on ? "border-sk-accent bg-sk-accent/[0.06]" : "border-sk-line bg-sk-card",
                  locked ? "cursor-not-allowed opacity-50" : "hover:border-sk-ink/30 active:scale-[0.97]"
                )}
              >
                <ProductImage src={s.image} alt="" className="w-full rounded-sk-card" zoomOnHover={false} />
                <span className="mt-2 truncate text-[13px] font-medium text-sk-ink">{s.title}</span>
                <span className="text-xs text-sk-muted">{s.detail ? `${s.detail} / Free` : "Free"}</span>
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{ scale: on ? 1 : 0.85, opacity: on ? 1 : 0.9 }}
                  transition={{ duration: reduce ? 0 : 0.2 }}
                  className={cn(
                    "absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-sk-chip border transition-colors duration-200",
                    on ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card"
                  )}
                >
                  {on ? <Check size={13} strokeWidth={3} /> : null}
                </motion.span>
              </button>
            </li>
          )
        })}
      </ul>
      <p className="mt-2 text-xs text-sk-muted">{full ? "Remove one to swap it for another." : "Samples ship with this order at no charge."}</p>
    </section>
  )
}

Usage

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

Free sample chooser, StoreKit