Gift options
This is a gift toggle that opens wrapping, a gift note with a character count and hidden prices.
componentgift-optionsbeautyanygift
$
npx shadcn@latest add @storekit/upsell-gift-optionsupsell-gift-options
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/upsell-gift-options$
pnpm dlx shadcn@latest add @storekit/upsell-gift-options$
yarn dlx shadcn@latest add @storekit/upsell-gift-options$
bunx --bun shadcn@latest add @storekit/upsell-gift-optionsSource
components/storekit/upsell/upsell-gift-options.tsx
180 lines
"use client"
import { useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Gift } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* "This is a gift" toggle. Switching it on opens wrap options, a gift note
with a live character count, and a choice to leave prices off the packing
slip. Everything is reported through onChange so the cart can store it as
line item properties. Wrap prices are integer cents. */
const EASE = [0.23, 1, 0.32, 1] as const
const RING = "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
type Wrap = { id: string; label: string; detail: string; price: number }
export type GiftOptionsValue = { isGift: boolean; wrapId: string; note: string; hidePrices: boolean }
const DEFAULT_WRAPS: Wrap[] = [
{ id: "sleeve", label: "Gift sleeve", detail: "Recycled card sleeve", price: 0 },
{ id: "box", label: "Gift box", detail: "Rigid box, ribbon tie", price: 600 },
]
export function UpsellGiftOptions({
wraps = DEFAULT_WRAPS,
maxNote = 200,
defaultOpen = false,
onChange,
className,
}: {
wraps?: Wrap[]
maxNote?: number
defaultOpen?: boolean
onChange?: (v: GiftOptionsValue) => void
className?: string
}) {
const reduce = useReducedMotion()
const uid = useId()
const sk = useSkBlock("upsell", "upsell-gift-options")
const [v, setV] = useState<GiftOptionsValue>({ isGift: defaultOpen, wrapId: wraps[0]?.id ?? "", note: "", hidePrices: true })
const wrapRefs = useRef<(HTMLButtonElement | null)[]>([])
const t = { duration: reduce ? 0 : 0.35, ease: EASE }
const update = (patch: Partial<GiftOptionsValue>) => {
const next = { ...v, ...patch }
setV(next)
onChange?.(next)
}
const toggle = () => {
sk.emit("interact", v.isGift ? "gift_off" : "gift_on", undefined)
update({ isGift: !v.isGift })
}
const pickWrap = (i: number, focus = false) => {
if (wraps[i].id !== v.wrapId) sk.emit("interact", `wrap_${wraps[i].id}`, wraps[i].price)
update({ wrapId: wraps[i].id })
if (focus) wrapRefs.current[i]?.focus()
}
const wrapIdx = Math.max(0, wraps.findIndex((w) => w.id === v.wrapId))
const left = maxNote - v.note.length
return (
<div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-card", className)}>
<button
type="button"
role="switch"
aria-checked={v.isGift}
aria-controls={`${uid}-panel`}
onClick={toggle}
className={cn("flex min-h-[60px] w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] sm:px-5", RING)}
>
<Gift size={18} className={cn("shrink-0 transition-colors duration-200", v.isGift ? "text-sk-accent" : "text-sk-muted")} aria-hidden />
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-sk-ink">This is a gift</span>
<span className="text-[13px] text-sk-muted">Add wrapping and a note</span>
</span>
<span aria-hidden className={cn("relative h-6 w-10 shrink-0 rounded-full transition-colors duration-200", v.isGift ? "bg-sk-accent" : "bg-sk-ink/[0.15]")}>
<motion.span
initial={false}
animate={{ x: v.isGift ? 16 : 0 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-sk-paper shadow-[0_1px_2px_rgb(var(--sk-ink)/0.2)]"
/>
</span>
</button>
<AnimatePresence initial={false}>
{v.isGift ? (
<motion.div id={`${uid}-panel`} key="panel" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={t} className="overflow-hidden">
<div className="flex flex-col gap-5 border-t border-sk-line px-4 py-4 sm:px-5 sm:py-5">
<div className="flex flex-col gap-2">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Wrapping</span>
<div role="radiogroup" aria-label="Wrapping" className="grid gap-2 sm:grid-cols-2">
{wraps.map((w, i) => {
const on = w.id === v.wrapId
return (
<button
key={w.id}
ref={(el) => {
wrapRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => pickWrap(i)}
onKeyDown={(e) => {
const f = e.key === "ArrowRight" || e.key === "ArrowDown"
const b = e.key === "ArrowLeft" || e.key === "ArrowUp"
if (!f && !b) return
e.preventDefault()
pickWrap((wrapIdx + (f ? 1 : -1) + wraps.length) % wraps.length, true)
}}
className={cn(
"flex min-h-[56px] items-center gap-3 rounded-sk-btn border px-3 py-2.5 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]",
on ? "border-sk-accent bg-sk-accent/[0.06]" : "border-sk-line hover:border-sk-ink/30",
RING
)}
>
<span className={cn("grid h-4 w-4 shrink-0 place-items-center rounded-full border", on ? "border-sk-accent" : "border-sk-line")}>
<motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2, ease: EASE }} className="h-2 w-2 rounded-full bg-sk-accent" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-sk-ink">{w.label}</span>
<span className="text-[12px] text-sk-muted">{w.detail}</span>
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-sk-ink">{w.price === 0 ? "Free" : `+${money(w.price)}`}</span>
</button>
)
})}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={`${uid}-note`} className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
Gift note
</label>
<span id={`${uid}-count`} aria-live="polite" className={cn("text-[12px] tabular-nums", left <= 20 ? "text-sk-sale" : "text-sk-muted")}>
{left} left
</span>
</div>
<textarea
id={`${uid}-note`}
value={v.note}
maxLength={maxNote}
rows={3}
aria-describedby={`${uid}-count`}
placeholder="Printed on a card inside the box"
onChange={(e) => update({ note: e.target.value.slice(0, maxNote) })}
className={cn(
"w-full resize-none rounded-sk-card border border-sk-line bg-sk-paper px-3 py-2.5 text-sm leading-[1.5] text-sk-ink placeholder:text-sk-muted/70 transition-[border-color] duration-200 focus:border-sk-accent",
RING
)}
/>
</div>
<label className="flex min-h-[44px] cursor-pointer items-center gap-3">
<input type="checkbox" checked={v.hidePrices} onChange={(e) => update({ hidePrices: e.target.checked })} className="peer sr-only" />
<span
aria-hidden
className="grid h-5 w-5 shrink-0 place-items-center rounded-[6px] border border-sk-line bg-sk-card text-sk-accent-ink transition-colors duration-200 peer-checked:border-sk-accent peer-checked:bg-sk-accent peer-focus-visible:ring-2 peer-focus-visible:ring-sk-accent peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-sk-paper"
>
<svg viewBox="0 0 16 16" className={cn("h-3.5 w-3.5 transition-opacity duration-200", v.hidePrices ? "opacity-100" : "opacity-0")} fill="none">
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="flex flex-col">
<span className="text-sm text-sk-ink">Hide prices on the packing slip</span>
<span className="text-[12px] text-sk-muted">Your receipt still goes to your email.</span>
</span>
</label>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-gift-options" and pass your product data. The demo above exercises the full prop surface with sample values.
