Frequently bought together
Three products with animated checkboxes, a flipping total, set savings and a single add button.
blockfbtanyatcbundle
$
npx shadcn@latest add @storekit/upsell-fbtupsell-fbt
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-fbt$
pnpm dlx shadcn@latest add @storekit/upsell-fbt$
yarn dlx shadcn@latest add @storekit/upsell-fbt$
bunx --bun shadcn@latest add @storekit/upsell-fbtSource
components/storekit/upsell/upsell-fbt.tsx
162 lines
"use client"
import { Fragment, useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Plus } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Frequently bought together. Each product toggles with an animated check;
the total flips as the selection changes and a bundle discount applies
when every item is in. */
type Item = { id: string; title: string; price: number; image: string }
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"
const DEFAULT_ITEMS: Item[] = [
{ id: "daily-greens", title: "Daily Greens", price: 4900, image: "/brands/morrow/greens.webp" },
{ id: "electrolytes", title: "Electrolytes", price: 3200, image: "/brands/morrow/electrolytes.webp" },
{ id: "magnesium-night", title: "Magnesium Night", price: 2900, image: "/brands/morrow/magnesium.webp" },
]
function Checkbox({ checked, reduce }: { checked: boolean; reduce: boolean | null }) {
return (
<span
aria-hidden
className={cn(
"grid h-5 w-5 place-items-center rounded-[6px] border transition-colors duration-200",
checked ? "border-sk-accent bg-sk-accent text-sk-accent-ink" : "border-sk-line bg-sk-card"
)}
>
<svg viewBox="0 0 16 16" className="h-3 w-3" fill="none">
<motion.path
d="M3.5 8.5l3 3 6-7"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}
/>
</svg>
</span>
)
}
export function UpsellFbt({
items = DEFAULT_ITEMS,
bundleDiscount = 0.1,
title = "Frequently bought together",
onAdd,
className,
}: {
items?: Item[]
/** Applied when every item is selected. */
bundleDiscount?: number
title?: string
onAdd?: (ids: string[]) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("upsell", "upsell-fbt")
const [selected, setSelected] = useState<string[]>(items.map((i) => i.id))
const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
const timers = useRef<number[]>([])
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])
const chosen = items.filter((i) => selected.includes(i.id))
const subtotal = chosen.reduce((s, i) => s + i.price, 0)
const all = chosen.length === items.length && items.length > 1
const total = all ? Math.round(subtotal * (1 - bundleDiscount)) : subtotal
const saved = subtotal - total
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))
const add = () => {
if (!chosen.length || status !== "idle") return
setStatus("loading")
sk.emit("convert", "add_to_cart", total)
timers.current.push(
window.setTimeout(() => {
setStatus("added")
onAdd?.(chosen.map((c) => c.id))
timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
}, 700)
)
}
return (
<section {...sk.attrs} ref={sk.ref as React.RefObject<HTMLElement>} className={cn("w-full rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)} aria-label={title}>
<h3 className="font-sk-display text-lg font-semibold tracking-[-0.02em] text-sk-ink">{title}</h3>
<div className="mt-5 grid gap-6 md:grid-cols-[1fr_220px] md:items-center">
<div className="flex items-start gap-2 sm:gap-3">
{items.map((item, i) => {
const on = selected.includes(item.id)
return (
<Fragment key={item.id}>
{i > 0 ? (
<span className="shrink-0 self-center pb-12 text-sk-muted" aria-hidden>
<Plus size={16} />
</span>
) : null}
<button
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggle(item.id)}
className={cn("group flex min-w-0 flex-1 flex-col gap-2 rounded-sk-btn text-left active:scale-[0.97] transition-transform duration-200", RING)}
>
<span className={cn("relative block aspect-square overflow-hidden rounded-sk-btn border bg-sk-surface transition-[border-color,opacity] duration-200", on ? "border-sk-line" : "border-sk-line opacity-50")}>
<img src={item.image} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
<span className="absolute left-2 top-2">
<Checkbox checked={on} reduce={reduce} />
</span>
</span>
<span className="line-clamp-2 min-h-[2.6em] text-[13px] font-medium leading-[1.3] text-sk-ink">{item.title}</span>
<span className="text-[13px] font-semibold tabular-nums text-sk-price">{money(item.price)}</span>
</button>
</Fragment>
)
})}
</div>
<div className="flex flex-col gap-3 border-t border-sk-line pt-5 md:border-l md:border-t-0 md:pl-6 md:pt-0">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Total</span>
<div className="relative h-9 overflow-hidden" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={total}
initial={{ opacity: 0, y: reduce ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -12 }}
transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}
className="absolute inset-x-0 top-0 flex items-baseline gap-2 tabular-nums"
>
<span className="text-2xl font-semibold text-sk-price">{money(total)}</span>
{saved > 0 ? <s className="text-sm text-sk-muted">{money(subtotal)}</s> : null}
</motion.span>
</AnimatePresence>
</div>
<p className={cn("text-[13px]", saved > 0 ? "text-sk-savings" : "text-sk-muted")}>
{saved > 0 ? `You save ${money(saved)} as a set` : `Add all ${items.length} to save ${Math.round(bundleDiscount * 100)}%`}
</p>
<Button onClick={add} loading={status === "loading"} disabled={!chosen.length} className="w-full">
{status === "added" ? (
<>
<Check size={16} strokeWidth={2.5} /> Added
</>
) : chosen.length ? (
`Add ${chosen.length} to cart`
) : (
"Select an item"
)}
</Button>
</div>
</div>
</section>
)
}
Usage
Import from "@/components/storekit/upsell/upsell-fbt" and pass your product data. The demo above exercises the full prop surface with sample values.