Cart subscribe switch
Per-line Subscribe and save switch that updates the line to the per-delivery price and reveals a cadence picker.
componentsubscribeanysubscribecart
$
npx shadcn@latest add @storekit/cart-subscribe-switchcart-subscribe-switch
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-subscribe-switch$
pnpm dlx shadcn@latest add @storekit/cart-subscribe-switch$
yarn dlx shadcn@latest add @storekit/cart-subscribe-switch$
bunx --bun shadcn@latest add @storekit/cart-subscribe-switchSource
components/storekit/cart/cart-subscribe-switch.tsx
138 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Repeat } 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"
/* Per-line subscribe and save in the cart. A switch converts the line to a
subscription, the line price drops to the per-delivery price with the
one-time price struck through, and a cadence picker appears. Terms are
stated plainly under it. */
export type SubscribeLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number; subscribed?: boolean; every?: number }
const SAMPLE: SubscribeLine[] = [
{ id: "magnesium-night", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1, subscribed: true, every: 30 },
{ id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
]
export function CartSubscribeSwitch({
lines: initial = SAMPLE,
percentOff = 15,
cadences = [30, 60, 90],
onChange,
className,
}: {
lines?: SubscribeLine[]
percentOff?: number
/** Delivery cadences in days. */
cadences?: number[]
onChange?: (lines: SubscribeLine[]) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-subscribe-switch")
const [lines, setLines] = React.useState(initial)
const sub = (cents: number) => Math.round((cents * (100 - percentOff)) / 100)
const total = lines.reduce((n, l) => n + (l.subscribed ? sub(l.price) : l.price) * l.quantity, 0)
const update = (id: string, patch: Partial<SubscribeLine>) => {
const next = lines.map((l) => (l.id === id ? { ...l, ...patch } : l))
setLines(next)
onChange?.(next)
}
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full", className)}>
<ul className="divide-y divide-sk-line border-y border-sk-line">
{lines.map((l) => {
const on = !!l.subscribed
const unit = on ? sub(l.price) : l.price
const every = l.every ?? cadences[0]
return (
<li key={l.id} className="py-4">
<div className="flex gap-3">
<ProductImage src={l.image} alt={l.title} className="h-16 w-16 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{l.title}</p>
<p className="text-[13px] text-sk-muted">
{l.variant ? `${l.variant} / ` : null}Qty <span className="tabular-nums">{l.quantity}</span>
</p>
</div>
<p className="shrink-0 text-right text-sm tabular-nums">
<span className={cn("block font-semibold", on ? "text-sk-savings" : "text-sk-price")}>{money(unit * l.quantity)}</span>
{on ? <s className="block text-xs text-sk-muted">{money(l.price * l.quantity)}</s> : null}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={on}
onClick={() => {
update(l.id, { subscribed: !on, every })
sk.emit(on ? "interact" : "convert", on ? "subscribe_off" : "subscribe_on", sub(l.price) * l.quantity)
}}
className="sk-btn mt-3 flex min-h-11 w-full items-center gap-3 rounded-sk-btn border border-sk-line px-3 py-2 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.99]"
>
<Repeat size={15} className={cn("shrink-0", on ? "text-sk-accent" : "text-sk-muted")} />
<span className="min-w-0 flex-1 text-[13px] font-medium text-sk-ink">
Subscribe and save <span className="tabular-nums">{percentOff}%</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", on ? "bg-sk-accent" : "bg-sk-ink/20")}>
<motion.span
initial={false}
animate={{ x: on ? 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)]", on ? "bg-sk-accent-ink" : "bg-sk-paper")}
/>
</span>
</button>
<AnimatePresence initial={false}>
{on ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pt-3">
<label htmlFor={`${l.id}-every`} className="text-[13px] text-sk-muted">
Deliver every
</label>
<select
id={`${l.id}-every`}
value={every}
onChange={(e) => update(l.id, { every: Number(e.target.value) })}
className="sk-btn h-10 rounded-sk-btn border border-sk-line bg-sk-card px-2 text-[13px] text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
>
{cadences.map((c) => (
<option key={c} value={c}>
{c} days
</option>
))}
</select>
<span className="text-xs tabular-nums text-sk-muted">{money(unit)} per delivery. Skip or cancel any time.</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</li>
)
})}
</ul>
<div className="flex items-baseline justify-between pt-3 text-sm">
<span className="text-sk-muted">Subtotal</span>
<span className="font-semibold tabular-nums">{money(total)}</span>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-subscribe-switch" and pass your product data. The demo above exercises the full prop surface with sample values.

