Add to cart with express row

Add to cart stays primary; express wallets sit beneath as a quieter row of text wordmarks.

blockexpress-rowanyatcexpress
$npx shadcn@latest add @storekit/upsell-express-row
upsell-express-row

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-express-row
$pnpm dlx shadcn@latest add @storekit/upsell-express-row
$yarn dlx shadcn@latest add @storekit/upsell-express-row
$bunx --bun shadcn@latest add @storekit/upsell-express-row

Source

components/storekit/upsell/upsell-express-row.tsx
131 lines
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Lock } 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"

/* Add to cart stays the primary action; express wallets sit beneath it as a
   quieter row so they do not compete with bundles or the cart. Wallets are
   plain text wordmarks in theme tokens. Swap in each provider's official
   button when you wire the real SDK. Amounts are integer cents. */

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"

type Wallet = { id: string; label: string }

const DEFAULT_WALLETS: Wallet[] = [
  { id: "shop", label: "Shop Pay" },
  { id: "apple", label: "Apple Pay" },
  { id: "google", label: "Google Pay" },
  { id: "paypal", label: "PayPal" },
]

export function UpsellExpressRow({
  total = 5900,
  wallets = DEFAULT_WALLETS,
  onAddToCart,
  onExpress,
  className,
}: {
  total?: number
  wallets?: Wallet[]
  onAddToCart?: () => void
  /** Called with the wallet id. Hand off to the provider's checkout here. */
  onExpress?: (walletId: string) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("upsell", "upsell-express-row")
  const [status, setStatus] = useState<"idle" | "loading" | "added">("idle")
  const [pending, setPending] = useState<string | null>(null)
  const timers = useRef<number[]>([])
  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])

  const add = () => {
    if (status !== "idle") return
    setStatus("loading")
    sk.emit("convert", "add_to_cart", total)
    timers.current.push(
      window.setTimeout(() => {
        setStatus("added")
        onAddToCart?.()
        timers.current.push(window.setTimeout(() => setStatus("idle"), 1200))
      }, 700)
    )
  }

  const express = (id: string) => {
    if (pending) return
    setPending(id)
    sk.emit("convert", `express_${id}`, total)
    onExpress?.(id)
    timers.current.push(window.setTimeout(() => setPending(null), 1200))
  }

  return (
    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn("flex w-full flex-col gap-3", className)}>
      <Button size="lg" className="w-full active:scale-[0.97]" loading={status === "loading"} onClick={add}>
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={status === "added" ? "added" : "add"}
            initial={{ opacity: 0, y: reduce ? 0 : 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduce ? 0 : -6 }}
            transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
            className="inline-flex items-center gap-2 tabular-nums"
          >
            {status === "added" ? (
              <>
                <Check size={16} strokeWidth={2.5} /> Added to cart
              </>
            ) : (
              <>Add to cart · {money(total)}</>
            )}
          </motion.span>
        </AnimatePresence>
      </Button>

      <div className="flex items-center gap-3 text-[12px] text-sk-muted" aria-hidden>
        <span className="h-px flex-1 bg-sk-line" />
        or check out now with
        <span className="h-px flex-1 bg-sk-line" />
      </div>

      <div role="group" aria-label="Express checkout" className={cn("grid gap-2", wallets.length >= 4 ? "grid-cols-2 sm:grid-cols-4" : wallets.length === 3 ? "grid-cols-3" : "grid-cols-2")}>
        {wallets.map((w) => {
          const busy = pending === w.id
          return (
            <button
              key={w.id}
              type="button"
              aria-label={`Check out with ${w.label}`}
              aria-busy={busy || undefined}
              disabled={!!pending && !busy}
              onClick={() => express(w.id)}
              className={cn(
                "relative h-11 rounded-sk-btn border border-sk-line bg-sk-card px-3 font-sk-display text-[15px] font-semibold tracking-[-0.01em] text-sk-ink transition-[border-color,background-color,opacity,transform] duration-200 hover:border-sk-ink/40 hover:bg-sk-ink/[0.03] active:scale-[0.97] disabled:opacity-50",
                RING
              )}
            >
              <span className={cn("transition-opacity duration-200", busy && "opacity-0")}>{w.label}</span>
              {busy ? (
                <span className="absolute inset-0 grid place-items-center">
                  <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" />
                </span>
              ) : null}
            </button>
          )
        })}
      </div>

      <p className="flex items-center justify-center gap-1.5 text-[12px] text-sk-muted">
        <Lock size={12} aria-hidden /> Express checkout skips the cart and uses your saved details.
      </p>
    </div>
  )
}

Usage

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

Add to cart with express row, StoreKit