# Checkout Bars

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

A shadcn/ui block from the [@01orbital](https://01orbital.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/01orbital/checkout-bars).

- Page, with a live preview: https://shadcn.exchange/r/01orbital/checkout-bars
- Screenshot: https://shadcn.exchange/shot/01orbital/checkout-bars/light.png
- Registry item: https://01orbital.dev/r/checkout-bars.json
- Categories: stepper
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/01orbital/checkout-bars.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@hookform/resolvers`, `react-hook-form`, `zod`
- Registry items: `@01orbital/button`, `card`, `@01orbital/field`, `@01orbital/input`, `@01orbital/stepper`

## Files

### `blocks/stepper/checkout-bars/index.tsx`

```tsx
"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>
  )
}
```
