docs / patterns / step-list

StepList

a vertical numbered process: a connector spine with a title and body per step. for how-it-works and onboarding sections.

intensity · structuresrsc · server

provenance

shaped from personal-website

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

a how-it-works section, alone or beside an illustrationwrap in RevealGroup for a staggered entrancenot a wizard. it explains a process, it does not run oneno long prose bodies. a step body is a sentence or two

a11y

an ordered list with a real heading per step · the connector spine is aria-hiddenjest-axe
live demo · try it out
  1. 01

    Sketch the flow

    Rough the screens and the path between them.

  2. 02

    Build the primitives

    Wire the tokens and the core components.

  3. 03

    Ship it

    Push to the registry and migrate the apps.

customize
stepshow many in the sequence
3
bodiesa sentence under each title
iconsreplace the step number with an icon
headingLevellevel of each step title
usagecopy
import { StepList } from "usva/patterns/step-list";

<StepList
  headingLevel="h3"
  steps={[
    { title: "Sketch the flow", body: "Rough the screens and the path between them." },
    { title: "Build the primitives", body: "Wire the tokens and the core components." },
    { title: "Ship it", body: "Push to the registry and migrate the apps." },
  ]}
/>

props

proptypedefaultnotes
stepsStep[]the steps: { title, body?, icon?, id? }. an icon replaces the number in the chip and moves the ordinal above the title.
headingLevel"h2" | "h3" | "h4""h4"level of each step title. pick it to fit the page outline.

get it

npx shadcn add https://usva.build/r/step-list.jsoncopy
source · components/ui/step-list.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";

export interface Step {
  /** Optional icon; falls back to the mono step number. */
  icon?: React.ReactNode;
  title: React.ReactNode;
  body?: React.ReactNode;
  /** Stable key; defaults to the index. */
  id?: string;
}

export interface StepListProps
  extends Omit<React.HTMLAttributes<HTMLOListElement>, "children"> {
  steps: Step[];
  /** Level of each step title. Pick it to fit the page outline. */
  headingLevel?: "h2" | "h3" | "h4";
}

export const StepList = React.forwardRef<HTMLOListElement, StepListProps>(
  ({ className, steps, headingLevel: Heading = "h4", ...props }, ref) => {
    const last = steps.length - 1;
    return (
      <ol ref={ref} className={cn("flex flex-col", className)} {...props}>
        {steps.map((step, i) => {
          const key = step.id ?? `step-${i}`;
          const ordinal = (i + 1).toString().padStart(2, "0");
          return (
            <li key={key} className="flex gap-4">
              <div className="flex flex-col items-center">
                <span className="grid size-11 shrink-0 place-items-center rounded-xl border border-border bg-surface-2 font-mono text-sm tabular-nums text-accent-alt [&_svg]:h-[1.15rem] [&_svg]:w-[1.15rem]">
                  {step.icon ?? ordinal}
                </span>
                {i < last && (
                  <span
                    aria-hidden="true"
                    className="mt-1 min-h-8 w-px flex-1 bg-border"
                  />
                )}
              </div>
              <div
                className={cn(
                  "min-w-0",
                  step.body == null && "flex min-h-11 flex-col justify-center",
                  i < last ? "pb-8" : "pb-0",
                )}
              >
                {step.icon != null && (
                  <p className="mb-1 font-mono text-[0.7rem] tabular-nums tracking-[0.16em] text-muted">
                    {ordinal}
                  </p>
                )}
                <Heading
                  className={cn(
                    "text-base font-semibold tracking-[-0.01em] text-ink",
                    step.body != null && "mb-1",
                  )}
                >
                  {step.title}
                </Heading>
                {step.body != null && (
                  <p className="text-sm leading-relaxed text-muted">
                    {step.body}
                  </p>
                )}
              </div>
            </li>
          );
        })}
      </ol>
    );
  },
);
StepList.displayName = "StepList";