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

Source

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.

Frequently bought together, StoreKit