Service add-ons
One-tap switches for gift wrap and priority handling with prices on the label and a running add-on total.
componentswitchanyaddon
$
npx shadcn@latest add @storekit/cart-service-addoncart-service-addon
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-service-addon$
pnpm dlx shadcn@latest add @storekit/cart-service-addon$
yarn dlx shadcn@latest add @storekit/cart-service-addon$
bunx --bun shadcn@latest add @storekit/cart-service-addonSource
components/storekit/cart/cart-service-addon.tsx
91 lines
"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Gift, Zap, type LucideIcon } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* One-tap service add-ons for the cart: gift wrap, priority handling and
the like. Each is a real switch with its price on the label, off by
default, and the running add-on total is shown underneath. */
export type ServiceAddon = { id: string; title: string; detail: string; price: number; icon?: "gift" | "priority" }
const ICONS: Record<NonNullable<ServiceAddon["icon"]>, LucideIcon> = { gift: Gift, priority: Zap }
const SAMPLE: ServiceAddon[] = [
{ id: "gift-wrap", title: "Gift wrap", detail: "Recycled paper, no prices on the packing slip.", price: 500, icon: "gift" },
{ id: "priority", title: "Priority handling", detail: "Packed first, ships the same business day before 2pm ET.", price: 800, icon: "priority" },
]
export function CartServiceAddon({
addons = SAMPLE,
defaultSelected = [],
onChange,
className,
}: {
addons?: ServiceAddon[]
defaultSelected?: string[]
onChange?: (selected: ServiceAddon[], total: number) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-service-addon")
const [on, setOn] = React.useState<Record<string, boolean>>(() => Object.fromEntries(defaultSelected.map((id) => [id, true])))
const total = addons.reduce((n, a) => n + (on[a.id] ? a.price : 0), 0)
const toggle = (a: ServiceAddon) => {
const nextOn = { ...on, [a.id]: !on[a.id] }
setOn(nextOn)
sk.emit("interact", `${a.id}_${nextOn[a.id] ? "on" : "off"}`, a.price)
const sel = addons.filter((x) => nextOn[x.id])
onChange?.(sel, sel.reduce((n, x) => n + x.price, 0))
}
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full rounded-sk-card border border-sk-line bg-sk-card", className)}>
<ul className="divide-y divide-sk-line">
{addons.map((a) => {
const checked = !!on[a.id]
const Icon = a.icon ? ICONS[a.icon] : Gift
return (
<li key={a.id}>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => toggle(a)}
className="flex min-h-[64px] w-full items-center gap-3 rounded-sk-card px-4 py-3 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sk-accent active:scale-[0.99]"
>
<span className={cn("grid h-9 w-9 shrink-0 place-items-center rounded-sk-chip transition-colors duration-200", checked ? "bg-sk-accent/15 text-sk-accent" : "bg-sk-ink/[0.06] text-sk-muted")}>
<Icon size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-sk-ink">
{a.title}{" "}
<span className="ml-1 whitespace-nowrap tabular-nums text-sk-muted">+{money(a.price)}</span>
</span>
<span className="block text-[13px] leading-[1.5] text-sk-muted">{a.detail}</span>
</span>
<span aria-hidden className={cn("relative flex h-6 w-10 shrink-0 items-center rounded-sk-chip p-0.5 transition-colors duration-200", checked ? "bg-sk-accent" : "bg-sk-ink/20")}>
<motion.span
initial={false}
animate={{ x: checked ? 16 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 34 }}
className={cn("h-5 w-5 rounded-sk-chip shadow-[0_1px_2px_rgb(0_0_0/0.25)]", checked ? "bg-sk-accent-ink" : "bg-sk-paper")}
/>
</span>
</button>
</li>
)
})}
</ul>
<div className="flex items-center justify-between border-t border-sk-line px-4 py-3 text-sm" aria-live="polite">
<span className="text-sk-muted">Add-ons</span>
<span className="font-medium tabular-nums">{total ? money(total) : "None"}</span>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-service-addon" and pass your product data. The demo above exercises the full prop surface with sample values.