# Stepper with filled status chevrons

> 8 proposal steps as nested arrows in a frame, green with a check once finished, solid blue with a spinner when current and dashed rings ahead.

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

- Page, with a live preview: https://shadcn.exchange/r/reui/c-stepper-33
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-stepper-33-light-f14ba465445c2ca1.png
- Registry item: https://reui.io/r/new-york-v4/c-stepper-33.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-33.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-33.tsx`

```tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { Frame } 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"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"

const steps = [
  { title: "Intake", description: "Log the brief, scope and deadline." },
  { title: "Structure", description: "Outline the sections and name owners." },
  { title: "Kick-off", description: "Brief writers on tone and timeline." },
  { title: "Draft", description: "Write a first pass of every section." },
  { title: "Consolidate", description: "Merge sections into one voice." },
  { title: "Internal check", description: "Legal and finance review terms." },
  { title: "Negotiate", description: "Settle open terms with procurement." },
  { title: "Package", description: "Export, sign and send the proposal." },
]

// The step in progress adds a center dot and spins the dashed ring around it.
function Ring({ active = false }: { active?: boolean }) {
  return (
    <span className="relative flex items-center justify-center">
      <IconPlaceholder
        lucide="CircleDashedIcon"
        tabler="IconCircleDashed"
        hugeicons="CircleDashedIcon"
        phosphor="CircleDashedIcon"
        remixicon="RiCheckboxBlankCircleLine"
        className={cn("size-3.5", active && "motion-safe:animate-spin")}
        aria-hidden="true"
      />
      {active && <span className="absolute size-1/6 rounded-full bg-current" />}
    </span>
  )
}

const indicators = {
  completed: (
    <IconPlaceholder
      lucide="CheckIcon"
      tabler="IconCheck"
      hugeicons="Tick02Icon"
      phosphor="CheckIcon"
      remixicon="RiCheckLine"
      className="size-3.5"
      aria-hidden="true"
    />
  ),
  inactive: <Ring />,
  loading: <Ring active />,
}

// A chevron cut from its box: --notch bites into the start edge and --arrow
// points out of the end edge, and --stretch scales both (as --n and --a) for a
// taller copy whose edges stay parallel. Every corner is rounded, so the clip
// traces each with three points, both ends of the curve and its middle; an end
// with no depth collapses them onto a straight edge. The shape mirrors whole in
// RTL, which is why its outer corners round the physical left and right.
function Chevron({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "absolute -z-10 [--a:calc(var(--arrow)*var(--stretch,1))] [--n:calc(var(--notch)*var(--stretch,1))] [clip-path:polygon(calc(var(--n)*0.26)_0,calc(100%_-_var(--a)*1.29)_0,calc(100%_-_var(--a)*1.04)_calc(var(--a)*0.07),calc(100%_-_var(--a)*0.86)_calc(var(--a)*0.25),calc(100%_-_var(--a)*0.14)_calc(50%_-_var(--a)*0.25),calc(100%_-_var(--a)*0.08)_50%,calc(100%_-_var(--a)*0.14)_calc(50%_+_var(--a)*0.25),calc(100%_-_var(--a)*0.86)_calc(100%_-_var(--a)*0.25),calc(100%_-_var(--a)*1.04)_calc(100%_-_var(--a)*0.07),calc(100%_-_var(--a)*1.29)_100%,calc(var(--n)*0.26)_100%,calc(var(--n)*0.13)_calc(100%_-_var(--n)*0.07),calc(var(--n)*0.13)_calc(100%_-_var(--n)*0.22),calc(var(--n)*0.86)_calc(50%_+_var(--n)*0.25),calc(var(--n)*0.92)_50%,calc(var(--n)*0.86)_calc(50%_-_var(--n)*0.25),calc(var(--n)*0.13)_calc(var(--n)*0.22),calc(var(--n)*0.13)_calc(var(--n)*0.07))] rtl:-scale-x-100",
        className
      )}
    />
  )
}

export function Pattern() {
  const [current, setCurrent] = useState(6)
  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

  // Past the collapsed width (many steps in a small box) the row scrolls, so
  // the current step comes back into view when it changes or the row resizes,
  // and a tab comes into view when focus lands on it (the browser leaves a
  // half-hidden one where it is). Only the row scrolls, never the page.
  const rowRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const row = rowRef.current
    if (!row) return
    const reveal = (tab: Element | null) => {
      if (!tab) return
      const box = row.getBoundingClientRect()
      const { left, right } = tab.getBoundingClientRect()
      if (left < box.left || right > box.right) {
        row.scrollLeft += (left + right - box.left - box.right) / 2
      }
    }
    const onFocus = (event: FocusEvent) =>
      reveal((event.target as Element).closest('[role="tab"]'))
    const observer = new ResizeObserver(() =>
      reveal(row.querySelector('[aria-selected="true"]'))
    )
    observer.observe(row)
    row.addEventListener("focusin", onFocus)
    return () => {
      observer.disconnect()
      row.removeEventListener("focusin", onFocus)
    }
  }, [current])

  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 flex flex-col items-center gap-6"
      aria-label="Proposal workflow"
    >
      {/* The design rounds the frame at radius-sm and the chevron ends 2px
          inside it; min() keeps lyra and sera square. Under 56rem the steps
          around the current one drop to their icon, under 24rem the current
          one too (the panel names it); past that the frame
          scrolls, and its 4px inset keeps the 2px focus ring inside the clip.
          min-w-max keeps the end padding in range. */}
      <Frame
        ref={rowRef}
        variant="inverse"
        className="border-input/50 relative max-w-full overflow-x-auto rounded-[min(var(--frame-radius),var(--radius-sm))] [--frame-panel-radius:min(var(--frame-radius),var(--radius-sm)_-_2px)] [--frame-px:--spacing(1)] [--frame-py:--spacing(1)]"
      >
        <StepperNav className="min-w-max gap-1">
          {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.
            // Steps hug their labels rather than share out the row.
            return (
              <StepperItem
                key={item.title}
                step={step}
                loading
                className="not-last:flex-none"
              >
                {/* The 8px notch reaches back over the 4px gap, so the
                    chevrons nest with an even channel between them. */}
                <StepperTrigger
                  tabIndex={allDone && step === steps.length ? 0 : undefined}
                  className="data-[state=completed]:text-success-foreground dark:data-[state=completed]:text-success relative isolate h-7 gap-1.5 ps-1.5 pe-4 [--arrow:--spacing(2)] [--notch:--spacing(2)] group-first/step:ps-2 group-first/step:[--notch:0px] group-last/step:pe-2.5 group-last/step:[--arrow:0px] focus-visible:ring-0 data-[state=active]:text-white"
                >
                  {/* The focus ring is the same chevron 2px larger all round,
                      so 32/28 of h-7 and --stretch is 8/7. The fill in front
                      of it is opaque, so only its outer 2px shows. */}
                  <Chevron className="bg-ring/50 -inset-y-0.5 -start-[calc(var(--notch)*var(--stretch)+--spacing(0.5))] -end-0.5 hidden [--stretch:8/7] group-first/step:rounded-l-[calc(var(--frame-panel-radius)+--spacing(0.5))] group-last/step:rounded-r-[calc(var(--frame-panel-radius)+--spacing(0.5))] in-focus-visible:block" />
                  {/* Each state tints ::after over an opaque background, the
                      layers the design uses: input at 40%, success at 20%,
                      and the palette blue-500 for the step in progress (the
                      theme has no blue token). */}
                  <Chevron className="bg-background after:bg-input/40 group-data-[state=completed]/step:after:bg-success/20 inset-y-0 -start-(--notch) end-0 overflow-hidden group-first/step:rounded-l-(--frame-panel-radius) group-last/step:rounded-r-(--frame-panel-radius) after:absolute after:inset-0 group-data-[state=active]/step:after:bg-blue-500" />

                  <StepperIndicator className="size-3.5 bg-transparent text-current data-[state=active]:bg-transparent data-[state=active]:text-current data-[state=completed]:bg-transparent data-[state=completed]:text-current" />
                  <StepperTitle className="whitespace-nowrap @max-4xl:not-data-[state=active]:sr-only @max-sm:sr-only">
                    {item.title}
                  </StepperTitle>
                  {step < current && (
                    <span className="sr-only">, completed</span>
                  )}
                </StepperTrigger>
              </StepperItem>
            )
          })}
        </StepperNav>
      </Frame>

      <StepperPanel className="text-center">
        {steps.map((item, index) => (
          <StepperContent key={item.title} value={index + 1}>
            <p className="text-muted-foreground text-xs">
              Step {index + 1} of {steps.length}
            </p>
            <p className="text-sm font-medium">{item.title}</p>
            <p className="text-muted-foreground text-sm">{item.description}</p>
          </StepperContent>
        ))}
        <StepperContent value={steps.length + 1}>
          <p className="text-muted-foreground text-xs">
            All {steps.length} steps done
          </p>
          <p className="text-sm font-medium">Proposal sent</p>
          <p className="text-muted-foreground text-sm">
            The packaged proposal is out with the client.
          </p>
        </StepperContent>
        <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>
      </StepperPanel>
    </Stepper>
  )
}
```
