Single-page checkout

Numbered Contact, Address, Method and Payment sections that collapse to summaries with Edit, beside a sticky order summary.

blocksingle-pageanycheckoutshipping-methodpaymentorder-summary
$npx shadcn@latest add @storekit/checkout-layout
checkout-layout

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

Source

components/storekit/checkout/checkout-layout.tsx
277 lines
"use client"

import * as React 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 { Input } from "@/components/storekit/ui/input"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Single-page checkout shell. Four numbered sections; the current one is
   open, finished ones collapse to a one-line summary with an Edit link. The
   order summary sits in a sticky right column on desktop. Replace the
   section bodies with your real forms. */

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

const SAMPLE: Line[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
]

const METHODS = [
  { id: "standard", label: "Standard", note: "3 to 5 business days", price: 0 },
  { id: "express", label: "Express", note: "1 to 2 business days", price: 1200 },
  { id: "overnight", label: "Overnight", note: "Next business day by 10am", price: 2800 },
]

const STEPS = ["Contact", "Shipping address", "Shipping method", "Payment"] as const

type Form = { email: string; name: string; address: string; city: string; zip: string; method: string; card: string; exp: string; cvc: string }

const DEFAULTS: Form = {
  email: "m.rahman@example.com",
  name: "Maya Rahman",
  address: "12 Elmwood Avenue",
  city: "Buffalo, NY",
  zip: "14201",
  method: "standard",
  card: "4242 4242 4242 4242",
  exp: "08 / 28",
  cvc: "123",
}

export function CheckoutLayout({
  items = SAMPLE,
  brandName = "Morrow",
  taxRate = 0.08,
  defaultValues = DEFAULTS,
  onPlaceOrder,
  className,
}: {
  items?: Line[]
  brandName?: string
  taxRate?: number
  defaultValues?: Form
  onPlaceOrder?: (form: Form) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("checkout", "checkout-layout")
  const [step, setStep] = React.useState(1)
  const [form, setForm] = React.useState<Form>(defaultValues)
  const [placing, setPlacing] = React.useState(false)
  const [placed, setPlaced] = React.useState(false)
  // Hand focus to the newly opened step so keyboard and screen reader users
  // are not dropped on <body> when the Continue button unmounts.
  const headings = React.useRef<(HTMLHeadingElement | null)[]>([])
  const stepMounted = React.useRef(false)
  React.useEffect(() => {
    if (!stepMounted.current) {
      stepMounted.current = true
      return
    }
    const h = headings.current[step]
    if (!h) return
    h.focus({ preventScroll: true })
    h.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" })
  }, [step, reduce])
  const set = (k: keyof Form) => (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [k]: e.target.value }))

  const method = METHODS.find((m) => m.id === form.method) ?? METHODS[0]
  const subtotal = items.reduce((n, l) => n + l.price * l.quantity, 0)
  const tax = Math.round(subtotal * taxRate)
  const total = subtotal + method.price + tax

  const summaries = [
    form.email,
    `${form.name}, ${form.address}, ${form.city} ${form.zip}`,
    `${method.label}, ${method.price ? money(method.price) : "Free"}`,
    `Card ending ${form.card.replace(/\s/g, "").slice(-4)}`,
  ]

  const place = () => {
    sk.emit("convert", "place_order", total)
    setPlacing(true)
    window.setTimeout(() => {
      setPlacing(false)
      setPlaced(true)
      setStep(4)
      onPlaceOrder?.(form)
    }, 1000)
  }

  const body = (i: number) => {
    switch (i) {
      case 0:
        return <Input label="Email" type="email" autoComplete="email" value={form.email} onChange={set("email")} hint="Order updates go here." />
      case 1:
        return (
          <div className="grid gap-4 sm:grid-cols-2">
            <div className="sm:col-span-2"><Input label="Full name" autoComplete="name" value={form.name} onChange={set("name")} /></div>
            <div className="sm:col-span-2"><Input label="Address" autoComplete="street-address" value={form.address} onChange={set("address")} /></div>
            <Input label="City, state" autoComplete="address-level2" value={form.city} onChange={set("city")} />
            <Input label="ZIP" inputMode="numeric" autoComplete="postal-code" value={form.zip} onChange={set("zip")} />
          </div>
        )
      case 2:
        return (
          <fieldset className="grid gap-2">
            <legend className="sr-only">Shipping method</legend>
            {METHODS.map((m) => (
              <label
                key={m.id}
                className={cn(
                  "flex min-h-14 cursor-pointer items-center gap-3 rounded-sk-card border px-4 transition-colors duration-200",
                  "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sk-accent",
                  form.method === m.id ? "border-sk-accent bg-sk-accent/[0.04]" : "border-sk-line hover:bg-sk-ink/[0.02]"
                )}
              >
                <input type="radio" name="sk-checkout-method" className="sr-only" checked={form.method === m.id} onChange={() => setForm((f) => ({ ...f, method: m.id }))} />
                <span aria-hidden className={cn("grid h-4 w-4 place-items-center rounded-full border", form.method === m.id ? "border-sk-accent" : "border-sk-muted/50")}>
                  {form.method === m.id ? <span className="h-2 w-2 rounded-full bg-sk-accent" /> : null}
                </span>
                <span className="flex-1 text-sm">
                  <span className="font-medium">{m.label}</span> <span className="text-sk-muted">{m.note}</span>
                </span>
                <span className={cn("text-sm tabular-nums", m.price ? "text-sk-price" : "font-medium text-sk-savings")}>{m.price ? money(m.price) : "Free"}</span>
              </label>
            ))}
          </fieldset>
        )
      default:
        return (
          <div className="grid grid-cols-2 gap-4">
            <div className="col-span-2"><Input label="Card number" inputMode="numeric" autoComplete="cc-number" value={form.card} onChange={set("card")} /></div>
            <Input label="Expiry" inputMode="numeric" autoComplete="cc-exp" value={form.exp} onChange={set("exp")} />
            <Input label="CVC" inputMode="numeric" autoComplete="cc-csc" value={form.cvc} onChange={set("cvc")} />
          </div>
        )
    }
  }

  const ease: [number, number, number, number] = [0.16, 1, 0.3, 1]

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("mx-auto grid w-full max-w-6xl grid-cols-1 gap-8 px-4 py-10 sm:px-8 lg:grid-cols-[minmax(0,1fr)_380px] lg:gap-12", className)}>
      <div className="min-w-0">
        <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{brandName}</p>
        <h2 className="mt-2 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em]">Checkout</h2>

        <AnimatePresence initial={false}>
          {placed ? (
            <motion.div
              initial={{ opacity: 0, y: reduce ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.4, ease }}
              className="mt-6 flex items-center gap-3 rounded-sk-card border border-sk-savings/30 bg-sk-savings/10 px-4 py-3 text-sm text-sk-ink"
              role="status"
            >
              <Check size={16} className="text-sk-savings" />
              Order placed. A confirmation is on its way to {form.email}.
            </motion.div>
          ) : null}
        </AnimatePresence>

        <ol className="mt-6 grid grid-cols-1 gap-3">
          {STEPS.map((label, i) => {
            const current = i === step && !placed
            const complete = i < step || placed
            return (
              <li key={label} className={cn("rounded-sk-card border bg-sk-card transition-colors duration-200", current ? "border-sk-ink/25" : "border-sk-line")}>
                <div className="flex min-h-16 items-center gap-4 px-5 py-3">
                  <span
                    className={cn(
                      "grid h-7 w-7 shrink-0 place-items-center rounded-full font-sk-mono text-xs tabular-nums transition-colors duration-200",
                      complete ? "bg-sk-savings/15 text-sk-savings" : current ? "bg-sk-accent text-sk-accent-ink" : "border border-sk-line text-sk-muted"
                    )}
                  >
                    {complete ? <Check size={14} strokeWidth={2.5} /> : String(i + 1).padStart(2, "0")}
                  </span>
                  <div className="min-w-0 flex-1">
                    <h3 ref={(el) => { headings.current[i] = el }} tabIndex={-1} className={cn("text-[15px] font-medium outline-none", !current && !complete && "text-sk-muted")}>{label}</h3>
                    {complete ? <p className="truncate text-sm text-sk-muted">{summaries[i]}</p> : null}
                  </div>
                  {complete && !placed ? (
                    <button
                      type="button"
                      onClick={() => setStep(i)}
                      aria-label={`Edit ${label.toLowerCase()}`}
                      className="-mr-2 min-h-11 rounded-sk-btn px-3 text-sm font-medium text-sk-accent underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
                    >
                      Edit
                    </button>
                  ) : null}
                </div>
                <AnimatePresence initial={false}>
                  {current ? (
                    <motion.div
                      key="body"
                      initial={{ height: 0, opacity: 0 }}
                      animate={{ height: "auto", opacity: 1 }}
                      exit={{ height: 0, opacity: 0 }}
                      transition={{ duration: reduce ? 0 : 0.36, ease }}
                      className="overflow-hidden"
                    >
                      <div className="grid grid-cols-1 gap-5 px-5 pb-5 pt-1">
                        {body(i)}
                        {i < STEPS.length - 1 ? (
                          <Button className="h-auto min-h-11 w-full whitespace-normal py-2.5 text-center leading-tight sm:w-auto sm:justify-self-start" onClick={() => setStep(i + 1)}>
                            Continue to {STEPS[i + 1].toLowerCase()}
                          </Button>
                        ) : (
                          <Button size="lg" className="w-full" loading={placing} onClick={place}>
                            <Lock size={14} />
                            Pay <span className="tabular-nums">{money(total)}</span>
                          </Button>
                        )}
                      </div>
                    </motion.div>
                  ) : null}
                </AnimatePresence>
              </li>
            )
          })}
        </ol>
      </div>

      <aside className="lg:sticky lg:top-6 lg:self-start">
        <div className="rounded-sk-card border border-sk-line bg-sk-surface/60 p-5 sm:p-6">
          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Order summary</p>
          <ul className="mt-4 grid grid-cols-1 gap-4">
            {items.map((l) => (
              <li key={l.id} className="flex items-center gap-3">
                <div className="relative shrink-0">
                  <ProductImage src={l.image} alt={l.title} className="h-14 w-14 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
                  <span className="absolute -right-2 -top-2 grid h-5 min-w-5 place-items-center rounded-full bg-sk-ink px-1.5 text-[11px] font-semibold tabular-nums leading-none text-sk-paper">
                    {l.quantity}
                  </span>
                </div>
                <div className="min-w-0 flex-1">
                  <p className="line-clamp-2 break-words text-sm font-medium">{l.title}</p>
                  {l.variant ? <p className="text-sm text-sk-muted">{l.variant}</p> : null}
                </div>
                <span className="shrink-0 text-sm tabular-nums text-sk-price">{money(l.price * l.quantity)}</span>
              </li>
            ))}
          </ul>
          <dl className="mt-5 grid gap-2 border-t border-sk-line pt-4 text-sm">
            <div className="flex justify-between"><dt className="text-sk-muted">Subtotal</dt><dd className="tabular-nums">{money(subtotal)}</dd></div>
            <div className="flex justify-between">
              <dt className="text-sk-muted">Shipping</dt>
              <dd className={cn("tabular-nums", !method.price && "font-medium text-sk-savings")}>{method.price ? money(method.price) : "Free"}</dd>
            </div>
            <div className="flex justify-between"><dt className="text-sk-muted">Estimated tax</dt><dd className="tabular-nums">{money(tax)}</dd></div>
            <div className="mt-2 flex items-baseline justify-between border-t border-sk-line pt-4">
              <dt className="font-medium">Total</dt>
              <dd className="font-sk-display text-2xl font-semibold tabular-nums tracking-[-0.02em]">{money(total)}</dd>
            </div>
          </dl>
        </div>
      </aside>
    </div>
  )
}

Usage

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