Saved for later
Cart with a saved-for-later list below it; lines move between the two and the subtotal only counts the cart.
componentlistanycartsave-for-later
$
npx shadcn@latest add @storekit/cart-saved-latercart-saved-later
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-saved-later$
pnpm dlx shadcn@latest add @storekit/cart-saved-later$
yarn dlx shadcn@latest add @storekit/cart-saved-later$
bunx --bun shadcn@latest add @storekit/cart-saved-laterSource
components/storekit/cart/cart-saved-later.tsx
135 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Bookmark, ShoppingBag, X } 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"
/* Cart with a saved-for-later list underneath. Lines move between the two
with a layout animation; the subtotal only counts what is in the cart.
Saving is the gentler alternative to removing. */
export type SavedLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number }
const SAMPLE_CART: SavedLine[] = [
{ id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 1 },
{ id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
]
const SAMPLE_SAVED: SavedLine[] = [{ id: "protein", title: "Protein", variant: "30 servings", price: 5400, image: "/brands/morrow/protein.webp", quantity: 1 }]
const LINK = "inline-flex h-11 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] font-medium text-sk-ink underline-offset-4 transition-colors duration-200 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97] sm:h-9"
export function CartSavedLater({
cart: initialCart = SAMPLE_CART,
saved: initialSaved = SAMPLE_SAVED,
onChange,
className,
}: {
cart?: SavedLine[]
saved?: SavedLine[]
onChange?: (v: { cart: SavedLine[]; saved: SavedLine[] }) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-saved-later")
const [cart, setCart] = React.useState(initialCart)
const [saved, setSaved] = React.useState(initialSaved)
const subtotal = cart.reduce((n, l) => n + l.price * l.quantity, 0)
const t = { duration: reduce ? 0 : 0.35, ease: [0.16, 1, 0.3, 1] as const }
const commit = (c: SavedLine[], s: SavedLine[]) => {
setCart(c)
setSaved(s)
onChange?.({ cart: c, saved: s })
}
const saveForLater = (l: SavedLine) => {
sk.emit("interact", "save_for_later", l.price)
commit(cart.filter((x) => x.id !== l.id), [l, ...saved])
}
const moveToCart = (l: SavedLine) => {
sk.emit("convert", "move_to_cart", l.price * l.quantity)
commit([...cart, l], saved.filter((x) => x.id !== l.id))
}
const removeSaved = (l: SavedLine) => commit(cart, saved.filter((x) => x.id !== l.id))
const row = (l: SavedLine, kind: "cart" | "saved") => (
<motion.li
key={l.id}
layout={!reduce}
layoutId={reduce ? undefined : `sk-saved-${l.id}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={t}
className="flex gap-3 border-b border-sk-line py-4 last:border-b-0"
>
<ProductImage src={l.image} alt={l.title} className={cn("shrink-0 rounded-sk-card border border-sk-line", kind === "cart" ? "h-20 w-20" : "h-16 w-16")} 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>
{l.variant ? <p className="text-[13px] text-sk-muted">{l.variant}</p> : null}
</div>
<p className={cn("shrink-0 text-sm font-semibold tabular-nums", kind === "cart" ? "text-sk-price" : "text-sk-muted")}>{money(l.price * l.quantity)}</p>
</div>
<div className="-ml-2 mt-1 flex items-center justify-between gap-1">
{kind === "cart" ? (
<button type="button" onClick={() => saveForLater(l)} className={LINK}>
<Bookmark size={14} />
Save for later
</button>
) : (
<>
<button type="button" onClick={() => moveToCart(l)} className={LINK}>
<ShoppingBag size={14} />
Move to cart
</button>
<button
type="button"
aria-label={`Remove ${l.title} from saved`}
onClick={() => removeSaved(l)}
className="grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97] sm:h-9 sm:w-9"
>
<X size={15} />
</button>
</>
)}
</div>
</div>
</motion.li>
)
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("w-full", className)}>
<div className="flex items-baseline justify-between border-b border-sk-line pb-3">
<h3 className="font-sk-display text-lg font-semibold tracking-[-0.02em]">
Cart <span className="tabular-nums text-sk-muted">({cart.length})</span>
</h3>
<p className="text-sm text-sk-muted">
Subtotal <span className="font-semibold tabular-nums text-sk-ink">{money(subtotal)}</span>
</p>
</div>
{cart.length === 0 ? <p className="py-6 text-sm text-sk-muted">Nothing in your cart. Saved items are below.</p> : null}
<ul>
<AnimatePresence initial={false} mode="popLayout">
{cart.map((l) => row(l, "cart"))}
</AnimatePresence>
</ul>
<div className="mt-6 rounded-sk-card bg-sk-surface px-4 pt-3">
<h4 className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
Saved for later (<span className="tabular-nums">{saved.length}</span>)
</h4>
{saved.length === 0 ? <p className="pb-4 pt-2 text-[13px] text-sk-muted">Items you save stay here without holding stock.</p> : null}
<ul>
<AnimatePresence initial={false} mode="popLayout">
{saved.map((l) => row(l, "saved"))}
</AnimatePresence>
</ul>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-saved-later" and pass your product data. The demo above exercises the full prop surface with sample values.



