Bars twin of Checkout: the same four steps validated with react-hook-form and zod, but progress is a row of rail segments with screen-reader titles instead of numbered triggers.
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import {
Stepper,
StepperContent,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// The copy: change text here, not in the markup.
const COPY = {
title: "Checkout",
back: "Back",
next: "Continue",
place: "Place order",
steps: ["Cart", "Shipping", "Payment", "Review"],
cart: { items: "2 items", total: "Total" },
shipping: {
name: "Full name",
address: "Address",
city: "City",
postcode: "Postcode",
},
payment: { card: "Card number", expiry: "Expiry", cvc: "CVC" },
review: "Everything below ships Thursday to the address you gave.",
required: "Required.",
cardError: "Enter the 16 digits on the card.",
expiryError: "Use MM / YY.",
cvcError: "Three or four digits.",
placed: (n: string) => `Order ${n} placed. A receipt is on its way to you.`,
} as const
const ITEMS = [
{ name: "Orbcore Desk Mat", price: "€48.00" },
{ name: "Felt Cable Tidy", price: "€12.00" },
] as const
const TOTAL = "€60.00"
const required = z.string().trim().min(1, COPY.required)
const schema = z.object({
name: required,
address: required,
city: required,
postcode: required,
card: z.string().regex(/^(\d\s?){16}$/, COPY.cardError),
expiry: z.string().regex(/^(0[1-9]|1[0-2])\s?\/\s?\d{2}$/, COPY.expiryError),
cvc: z.string().regex(/^\d{3,4}$/, COPY.cvcError),
})
type Values = z.infer<typeof schema>
// The fields each step must pass before Continue; Enter submits the step.
const FIELDS: (keyof Values)[][] = [
[],
["name", "address", "city", "postcode"],
["card", "expiry", "cvc"],
[],
]
// The bars twin of checkout: same panels, copy and validation; the rail is segments.
// The integration point: send the values to your checkout API and return the
// order it created. This stand-in returns a fixed number.
async function placeOrder(values: Values): Promise<{ number: string }> {
void values
return { number: "#48214" }
}
export default function CheckoutBars() {
const id = useId()
const [step, setStep] = useState(0)
const [placed, setPlaced] = useState<string | null>(null)
const last = COPY.steps.length - 1
const form = useForm<Values>({
resolver: zodResolver(schema),
mode: "onTouched",
defaultValues: {
name: "",
address: "",
city: "",
postcode: "",
card: "",
expiry: "",
cvc: "",
},
})
// Validate only the open step, then advance; the last step places the order.
const advance = async () => {
if (!(await form.trigger(FIELDS[step]))) return
if (step < last) setStep(step + 1)
else setPlaced((await placeOrder(form.getValues())).number)
}
const placedRef = useRef<HTMLParagraphElement>(null)
useEffect(() => {
if (placed) placedRef.current?.focus()
}, [placed])
const text = (
name: keyof Values,
label: string,
props: React.ComponentProps<typeof Input> = {}
) => (
<Controller
name={name}
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}co-${name}`}>{label}</FieldLabel>
<Input
{...field}
id={`${id}co-${name}`}
aria-invalid={fieldState.invalid}
{...props}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)
return (
<Card className="mx-auto w-full max-w-lg">
<CardHeader>
<CardTitle>{COPY.title}</CardTitle>
</CardHeader>
<form
className="flex flex-col gap-(--card-spacing)"
onSubmit={(e) => {
e.preventDefault()
void advance()
}}
>
<CardContent>
<p
ref={placedRef}
tabIndex={-1}
aria-live="polite"
className={placed ? "text-sm outline-none" : "sr-only"}
>
{placed ? COPY.placed(placed) : ""}
</p>
{!placed && (
<Stepper value={step} onValueChange={setStep} variant="bars">
<StepperList>
{COPY.steps.map((title, i) => (
<StepperItem key={title} value={i}>
<StepperSeparator />
<StepperTitle className="sr-only">{title}</StepperTitle>
</StepperItem>
))}
</StepperList>
<StepperContent keepMounted value={0}>
<ul className="divide-y divide-border text-sm">
{ITEMS.map((it) => (
<li key={it.name} className="flex justify-between py-2">
<span>{it.name}</span>
<span className="tabular-nums">{it.price}</span>
</li>
))}
<li className="flex justify-between py-2 font-medium">
<span>{COPY.cart.total}</span>
<span className="tabular-nums">{TOTAL}</span>
</li>
</ul>
</StepperContent>
<StepperContent keepMounted value={1}>
<FieldGroup>
{text("name", COPY.shipping.name, { autoComplete: "name" })}
{text("address", COPY.shipping.address, {
autoComplete: "street-address",
})}
<div className="grid grid-cols-2 gap-4">
{text("city", COPY.shipping.city, {
autoComplete: "address-level2",
})}
{text("postcode", COPY.shipping.postcode, {
autoComplete: "postal-code",
})}
</div>
</FieldGroup>
</StepperContent>
<StepperContent keepMounted value={2}>
<FieldGroup>
{text("card", COPY.payment.card, {
inputMode: "numeric",
autoComplete: "cc-number",
placeholder: "4242 4242 4242 4242",
})}
<div className="grid grid-cols-2 gap-4">
{text("expiry", COPY.payment.expiry, {
autoComplete: "cc-exp",
placeholder: "MM / YY",
})}
{text("cvc", COPY.payment.cvc, {
inputMode: "numeric",
autoComplete: "cc-csc",
})}
</div>
</FieldGroup>
</StepperContent>
<StepperContent keepMounted value={3}>
<p className="text-sm text-muted-foreground">{COPY.review}</p>
<p className="text-sm">
{COPY.cart.items} ·{" "}
<span className="font-medium tabular-nums">{TOTAL}</span>
</p>
</StepperContent>
</Stepper>
)}
</CardContent>
{!placed && (
<CardFooter className="justify-between">
<Button
type="button"
variant="outline"
disabled={step === 0}
onClick={() => setStep(step - 1)}
>
{COPY.back}
</Button>
<Button type="submit">
{step < last ? COPY.next : COPY.place}
</Button>
</CardFooter>
)}
</form>
</Card>
)
}
pnpm dlx shadcn add @01orbital/checkout-barsnpx shadcn add @01orbital/checkout-barsyarn shadcn add @01orbital/checkout-barsbunx --bun shadcn add @01orbital/checkout-barsFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/blocks/stepper/checkout-bars/index.tsx, as source you own and edit.
import CheckoutBars from "@/components/blocks/stepper/checkout-bars"
export default function Example() {
return <CheckoutBars />
}npm: @hookform/resolvers, react-hook-form, zod. The CLI installs them for you.
Registry pieces it installs with it:
buttoncardfieldinputCheckout
Four-step checkout card (cart, shipping, payment, review) with numbered, clickable step triggers that turn to checks. Each step validates with react-hook-form and zod before Continue.
Compact Steps
Small new-project card that walks three steps (name, details, access) with a row of dot indicators and the current step title beside them, for dialogs where a full stepper is too loud.