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-options
upsell-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-options

Source

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.

Gift options, StoreKit