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-layoutcheckout-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-layoutSource
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.