Buy more, save more

Line-level quantity break message with one-click add of the missing units; shows the applied discount once reached.

componenttiersanyquantitydiscountbulk
$npx shadcn@latest add @storekit/cart-buy-more
cart-buy-more

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-buy-more
$pnpm dlx shadcn@latest add @storekit/cart-buy-more
$yarn dlx shadcn@latest add @storekit/cart-buy-more
$bunx --bun shadcn@latest add @storekit/cart-buy-more

Source

components/storekit/cart/cart-buy-more.tsx
116 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Plus, Tag } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { Stepper } from "@/components/storekit/ui/stepper"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Buy more, save more at the line level. The line names the next quantity
   break and adds the missing units in one click. Once a break is reached the
   discount shows as applied and the message moves to the next break. */

export type QtyBreak = { qty: number; percentOff: number }

export type BuyMoreLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number }

const SAMPLE: BuyMoreLine = { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 1 }
const BREAKS: QtyBreak[] = [
  { qty: 2, percentOff: 10 },
  { qty: 3, percentOff: 15 },
]

export function CartBuyMore({
  line = SAMPLE,
  breaks = BREAKS,
  max = 10,
  onQuantityChange,
  className,
}: {
  line?: BuyMoreLine
  breaks?: QtyBreak[]
  max?: number
  onQuantityChange?: (qty: number) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-buy-more")
  const [qty, setQty] = React.useState(line.quantity)
  const sorted = React.useMemo(() => [...breaks].sort((a, b) => a.qty - b.qty), [breaks])
  const active = [...sorted].reverse().find((b) => qty >= b.qty)
  const next = sorted.find((b) => qty < b.qty)
  const pct = active?.percentOff ?? 0
  const full = line.price * qty
  const total = Math.round((full * (100 - pct)) / 100)

  const set = (q: number) => {
    setQty(q)
    onQuantityChange?.(q)
  }
  const addMissing = () => {
    if (!next) return
    const add = next.qty - qty
    set(next.qty)
    sk.emit("convert", "buy_more", line.price * add)
  }

  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 p-4", className)}>
      <div className="flex gap-3">
        <ProductImage src={line.image} alt={line.title} className="h-20 w-20 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <div className="flex items-start justify-between gap-3">
            <div className="min-w-0">
              <p className="truncate text-sm font-medium">{line.title}</p>
              {line.variant ? <p className="text-[13px] text-sk-muted">{line.variant}</p> : null}
            </div>
            <p className="shrink-0 text-right text-sm tabular-nums">
              <span className={cn("block font-semibold", pct ? "text-sk-savings" : "text-sk-price")}>{money(total)}</span>
              {pct ? <s className="block text-xs text-sk-muted">{money(full)}</s> : null}
            </p>
          </div>
          <Stepper value={qty} onChange={set} size="sm" max={max} className="self-start" />
        </div>
      </div>

      <div className="mt-3 grid grid-cols-1 gap-2" aria-live="polite">
        <AnimatePresence initial={false} mode="popLayout">
          {active ? (
            <motion.p
              key={`on-${active.qty}`}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
              className="flex items-center gap-2 text-[13px] font-medium text-sk-savings"
            >
              <Check size={14} />
              <span>
                <span className="tabular-nums">{active.percentOff}%</span> off applied, you save <span className="tabular-nums">{money(full - total)}</span>
              </span>
            </motion.p>
          ) : null}
        </AnimatePresence>
        {next && qty < max ? (
          <div className="flex items-center gap-3 rounded-sk-btn bg-sk-surface px-3 py-2">
            <Tag size={14} className="shrink-0 text-sk-muted" />
            <p className="min-w-0 flex-1 text-[13px] text-sk-ink">
              Add <span className="tabular-nums">{next.qty - qty}</span> more for <span className="font-semibold tabular-nums">{next.percentOff}%</span> off this item
            </p>
            <button
              type="button"
              onClick={addMissing}
              className="sk-btn inline-flex h-10 shrink-0 items-center gap-1 rounded-sk-btn bg-sk-accent px-3 text-[13px] font-medium text-sk-accent-ink transition-[filter,transform] duration-200 hover:brightness-[1.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]"
            >
              <Plus size={14} />
              Add <span className="tabular-nums">{next.qty - qty}</span>
            </button>
          </div>
        ) : null}
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/cart/cart-buy-more" and pass your product data. The demo above exercises the full prop surface with sample values.

Buy more, save more, StoreKit