# Stepper with top color bars and card art

> Adding a payment card in 4 steps, each tab with its own color bar on the top edge that thickens when selected, over a card drawing and a caption.

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-stepper-23).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-stepper-23
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-stepper-23-light-52dc57a2c675ff1e.png
- Registry item: https://reui.io/r/new-york-v4/c-stepper-23.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-stepper-23.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: `cn`
- Registry items: `button`, `@reui/frame`, `@reui/stepper`

## Files

### `c-stepper-23.tsx`

```tsx
"use client"

import { useRef, useState } from "react"
import { Frame, FramePanel } from "@/components/reui/frame"
import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperNav,
  StepperPanel,
  StepperTitle,
  StepperTrigger,
} from "@/components/reui/stepper"
import { cn } from "cn"

import { Button } from "@/components/ui/button"

// Each step owns a color. Its column sets it as the text color, and the bar
// and indicator inherit it through currentColor. The theme has no blue token,
// so step 3 takes the palette color.
const steps = [
  { color: "text-info", caption: "Enter the card number and expiry" },
  { color: "text-warning", caption: "Add the billing address" },
  { color: "text-blue-500", caption: "Confirm the card with your bank" },
  { color: "text-success", caption: "Review and save the card" },
]

// The design's 3:1 dashed ring on a 12px box, its 1.4 stroke overflowing the
// box. pathLength 36 closes the dashes in nine even segments, turned to start
// at 1 o'clock. Loading dims the track under a 38.5% arc that rests at 11 to
// 4 o'clock when motion is reduced.
function Ring({ spin = false }: { spin?: boolean }) {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.4"
      aria-hidden="true"
      className="size-3 overflow-visible"
    >
      <circle
        cx="6"
        cy="6"
        r="6"
        strokeDasharray="3 1"
        pathLength={36}
        transform="rotate(-54 6 6)"
        strokeOpacity={spin ? 0.4 : 1}
      />
      {spin && (
        <circle
          cx="6"
          cy="6"
          r="6"
          strokeDasharray="38.5 61.5"
          strokeDashoffset={32.7}
          pathLength={100}
          className="origin-center motion-safe:animate-spin"
        />
      )}
    </svg>
  )
}

// Off the active step the indicator sits at 40% opacity, which dims these
// too. Completed and loading are spelled out for screen readers as well.
const indicators = {
  completed: (
    <>
      <svg
        viewBox="0 0 14 14"
        fill="none"
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="size-3.5"
      >
        <circle cx="7" cy="7" r="6.3" strokeWidth="1.4" />
        <path d="m4.33 7.15 1.67 1.54 3.67-3.38" strokeWidth="1.5" />
      </svg>
      <span className="sr-only">Completed</span>
    </>
  ),
  inactive: <Ring />,
  loading: (
    <>
      <Ring spin />
      <span className="sr-only">In progress</span>
    </>
  ),
}

function CardIllustration() {
  return (
    <svg
      width="200"
      height="130"
      viewBox="0 0 200 130"
      fill="none"
      aria-hidden="true"
    >
      <rect
        x="48"
        y="12"
        width="120"
        height="78"
        rx="10"
        transform="rotate(6 108 51)"
        className="fill-muted/50 dark:fill-muted/25"
      />
      <rect
        x="36"
        y="18"
        width="128"
        height="82"
        rx="10"
        strokeWidth="1.5"
        className="fill-background stroke-border"
      />
      <rect
        x="52"
        y="38"
        width="22"
        height="16"
        rx="3"
        className="fill-muted-foreground/15 stroke-muted-foreground/20"
      />
      <path
        d="M52 46H74M63 38V54"
        strokeWidth="0.8"
        className="stroke-muted-foreground/15"
      />
      <path
        d="M84 42Q87 46 84 50M88 40Q92 46 88 52M92 38Q97 46 92 54"
        strokeWidth="1.5"
        strokeLinecap="round"
        className="stroke-muted-foreground/20"
      />
      {[56, 64, 72, 80, 94, 102, 110, 118].map((cx) => (
        <circle
          key={cx}
          cx={cx}
          cy="68"
          r="2"
          className={
            cx < 90 ? "fill-muted-foreground/20" : "fill-muted-foreground/15"
          }
        />
      ))}
      <rect
        x="52"
        y="80"
        width="40"
        height="3"
        rx="1.5"
        className="fill-muted-foreground/12"
      />
      <rect
        x="120"
        y="80"
        width="28"
        height="3"
        rx="1.5"
        className="fill-muted-foreground/12"
      />
      <circle cx="140" cy="88" r="6" className="fill-muted-foreground/8" />
      <circle cx="150" cy="88" r="6" className="fill-muted-foreground/12" />
      <circle cx="26" cy="50" r="3" className="fill-primary/10" />
      <circle cx="180" cy="40" r="2" className="fill-primary/10" />
      <path d="M174 70L178 66V74Z" className="fill-muted-foreground/10" />
    </svg>
  )
}

export function Pattern() {
  const [current, setCurrent] = useState(2)
  const prevRef = useRef<HTMLButtonElement>(null)
  const nextRef = useRef<HTMLButtonElement>(null)
  // One past the last step marks every step complete. No tab is selected
  // then, so the last one keeps the tablist's tab stop.
  const allDone = current > steps.length

  function goTo(step: number) {
    setCurrent(step)
    // The pressed button disables itself at either end, so focus moves to
    // its partner.
    if (step === 1) nextRef.current?.focus()
    if (step > steps.length) prevRef.current?.focus()
  }

  return (
    <Stepper
      value={current}
      onValueChange={setCurrent}
      indicators={indicators}
      className="@container max-w-175"
      aria-label="Add a card"
    >
      <Frame className="w-full" variant="ghost">
        <FramePanel>
          {/* The nav bleeds over the panel's top and side padding, so the bars
              sit flush with its edges and the panel's overflow-hidden rounds
              their ends. Only a block, auto-width nav stretches into those
              margins, and the tabs' focus ring is inset so the clip spares it. */}
          <StepperNav className="-mx-(--frame-panel-px) -mt-(--frame-panel-py) flex data-[orientation=horizontal]:w-auto">
            {steps.map((item, index) => {
              const step = index + 1

              // The primitive shows loading on the active step only, so every
              // step carries the flag and the spinner follows the selection.
              return (
                <StepperItem
                  key={item.caption}
                  step={step}
                  loading
                  className="min-w-0 flex-1"
                >
                  <StepperTrigger
                    tabIndex={allDone && step === steps.length ? 0 : undefined}
                    className="w-full min-w-0 rounded-none focus-visible:ring-inset"
                  >
                    {/* 30px: the 4px bar, a 12px gap and the 14px label row,
                        so the active bar grows into the gap and the row
                        never moves. */}
                    <span
                      className={cn(
                        "flex h-7.5 min-w-0 flex-1 flex-col justify-between",
                        item.color
                      )}
                    >
                      {/* The active bar's corners stop at the panel radius,
                          so the square styles stay square. */}
                      <span
                        aria-hidden="true"
                        className="h-1 bg-current group-data-[state=active]/step:h-2 group-data-[state=active]/step:rounded-b-[min(var(--radius-sm)_-_2px,var(--frame-panel-radius))]"
                      />
                      <span className="flex items-center justify-between gap-1 px-3 whitespace-nowrap">
                        {/* Under 28rem a quarter row only fits the number, so
                            the word moves to sr-only and the tab keeps its
                            name. */}
                        <StepperTitle className="text-current/60 data-[state=active]:text-current">
                          <span className="@max-md:sr-only">Step </span>
                          {step}
                        </StepperTitle>
                        {/* The primitive fills each state with its own color,
                            so each is cleared for the icon to take the step's. */}
                        <StepperIndicator className="size-3 overflow-visible bg-transparent text-current opacity-40 data-[state=active]:bg-transparent data-[state=active]:text-current data-[state=active]:opacity-100 data-[state=completed]:size-3.5 data-[state=completed]:bg-transparent data-[state=completed]:text-current" />
                      </span>
                    </span>
                  </StepperTrigger>
                </StepperItem>
              )
            })}
          </StepperNav>

          {/* The card art is a static empty state; only the caption under
              it follows the active step. */}
          <StepperPanel className="mt-6 flex flex-col items-center">
            <CardIllustration />
            {steps.map((item, index) => (
              <StepperContent key={item.caption} value={index + 1}>
                <p className="text-muted-foreground text-center text-sm">
                  {item.caption}
                </p>
              </StepperContent>
            ))}
            <StepperContent value={steps.length + 1}>
              <p className="text-muted-foreground text-center text-sm">
                Card saved and ready to use
              </p>
            </StepperContent>
          </StepperPanel>

          <div className="mt-3 flex justify-center gap-2">
            <Button
              ref={prevRef}
              variant="outline"
              size="sm"
              disabled={current === 1}
              onClick={() => goTo(current - 1)}
            >
              Previous
            </Button>
            <Button
              ref={nextRef}
              variant="outline"
              size="sm"
              disabled={allDone}
              onClick={() => goTo(current + 1)}
            >
              Next
            </Button>
          </div>
        </FramePanel>
      </Frame>
    </Stepper>
  )
}
```
