Gift unlock
Locked row that names the amount left, then opens a choose-one-of-three gift picker at the threshold.
componentgiftanygiftshipping-threshold
$
npx shadcn@latest add @storekit/cart-gift-unlockcart-gift-unlock
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-gift-unlock$
pnpm dlx shadcn@latest add @storekit/cart-gift-unlock$
yarn dlx shadcn@latest add @storekit/cart-gift-unlock$
bunx --bun shadcn@latest add @storekit/cart-gift-unlockSource
components/storekit/cart/cart-gift-unlock.tsx
134 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Gift, Lock } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Gift unlock. Below the threshold it is a quiet locked row with the amount
left. At the threshold a chooser opens with three gifts; the shopper picks
one and it joins the order at no charge. Dropping below the threshold
releases the pick. */
export type CartGift = { id: string; title: string; detail?: string; value: number; image: string }
const SAMPLE_GIFTS: CartGift[] = [
{ id: "travel-sticks", title: "Electrolyte sticks", detail: "5 sticks", value: 1200, image: "/brands/morrow/electrolytes.webp" },
{ id: "greens-travel", title: "Daily Greens", detail: "Travel size", value: 1800, image: "/brands/morrow/greens.webp" },
{ id: "magnesium-trial", title: "Magnesium Night", detail: "7-night pack", value: 1500, image: "/brands/morrow/magnesium.webp" },
]
export function CartGiftUnlock({
subtotal = 16200,
threshold = 15000,
gifts = SAMPLE_GIFTS,
defaultGiftId,
onChoose,
className,
}: {
subtotal?: number
threshold?: number
gifts?: CartGift[]
defaultGiftId?: string
onChoose?: (gift: CartGift | null) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-gift-unlock")
const [picked, setPicked] = React.useState<string | undefined>(defaultGiftId)
const unlocked = subtotal >= threshold
const remaining = Math.max(0, threshold - subtotal)
const groupId = React.useId()
React.useEffect(() => {
if (!unlocked && picked) {
setPicked(undefined)
onChoose?.(null)
}
}, [unlocked, picked, onChoose])
const choose = (g: CartGift) => {
setPicked(g.id)
sk.emit("convert", "gift_select", g.value)
onChoose?.(g)
}
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-card", className)}>
<div className="flex items-center gap-3 p-4">
<span className={cn("grid h-9 w-9 shrink-0 place-items-center rounded-sk-chip transition-colors duration-300", unlocked ? "bg-sk-accent text-sk-accent-ink" : "bg-sk-ink/[0.06] text-sk-muted")}>
{unlocked ? <Gift size={16} /> : <Lock size={15} />}
</span>
<div className="min-w-0 flex-1" aria-live="polite">
{unlocked ? (
<>
<p id={groupId} className="text-sm font-medium text-sk-ink">Choose your free gift</p>
<p className="text-[13px] text-sk-muted">{picked ? "Added to your order at no charge." : "Pick one. It ships with this order."}</p>
</>
) : (
<>
<p className="text-sm font-medium text-sk-ink">
Spend <span className="tabular-nums">{money(remaining)}</span> more for a free gift
</p>
<p className="text-[13px] text-sk-muted">You will choose from {gifts.length} options.</p>
</>
)}
</div>
</div>
<AnimatePresence initial={false}>
{unlocked ? (
<motion.div
key="gifts"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.4, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div role="radiogroup" aria-labelledby={groupId} className="grid grid-cols-3 gap-2 border-t border-sk-line p-3 sm:gap-3 sm:p-4">
{gifts.slice(0, 3).map((g, i) => {
const on = picked === g.id
return (
<motion.button
key={g.id}
type="button"
role="radio"
aria-checked={on}
onClick={() => choose(g)}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.4, delay: reduce ? 0 : 0.08 + i * 0.05, ease: [0.16, 1, 0.3, 1] }}
className={cn(
"relative flex min-w-0 flex-col rounded-sk-card border p-2 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card",
on ? "border-sk-accent bg-sk-accent/[0.06]" : "border-sk-line hover:border-sk-ink/30"
)}
>
<ProductImage src={g.image} alt="" className="w-full rounded-sk-card" zoomOnHover={false} />
<span className="mt-2 line-clamp-2 hyphens-auto break-words text-[13px] font-medium leading-tight text-sk-ink">{g.title}</span>
{g.detail ? <span className="text-xs text-sk-muted">{g.detail}</span> : null}
<span className="mt-1 text-xs tabular-nums text-sk-muted">
<s>{money(g.value)}</s> <span className="font-medium text-sk-savings">Free</span>
</span>
<span
aria-hidden
className={cn(
"absolute right-1.5 top-1.5 grid h-5 w-5 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={12} strokeWidth={3} /> : null}
</span>
</motion.button>
)
})}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-gift-unlock" and pass your product data. The demo above exercises the full prop surface with sample values.