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-samplescart-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-samplesSource
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.