Vela UI Docsvenizia.ai

Stepper

Shows progress through a sequence of steps, with active, complete, and inactive states.

npx shadcn@latest add @velaui/stepper
npx shadcn@latest add @velaui/stepper

Examples

  • Default
  • Optional
  • Vertical
  • Center

View code

Stepper · Default

Installation

pnpm dlx velaui add @velaui/stepper
pnpm dlx velaui add @velaui/stepper

Code

stepper-default.tsx
import { Stepper } from "@/components/ui/stepper";

const STEPS = [
  { title: "Account" },
  { title: "Billing" },
  { title: "Confirm" },
];

export default function Example() {
  return (
    <Stepper steps={STEPS} current={1} />
  );
}

View code

Stepper · Optional

Installation

pnpm dlx velaui add @velaui/stepper
pnpm dlx velaui add @velaui/stepper

Code

stepper-optional.tsx
import { Stepper } from "@/components/ui/stepper";

const STEPS = [
  { title: "Shipping address", optional: "Optional" },
  { title: "Payment method" },
  { title: "Review order" },
];

export default function Example() {
  return (
    <Stepper steps={STEPS} current={2} className="w-full" />
  );
}

View code

Stepper · Vertical

Installation

pnpm dlx velaui add @velaui/stepper
pnpm dlx velaui add @velaui/stepper

Code

stepper-vertical.tsx
import { Stepper } from "@/components/ui/stepper";

const STEPS = [
  { title: "Account" },
  { title: "Billing" },
  { title: "Confirm" },
];

export default function Example() {
  return (
    <Stepper steps={STEPS} current={1} orientation="vertical" />
  );
}

View code

Stepper · Center

Installation

pnpm dlx velaui add @velaui/stepper
pnpm dlx velaui add @velaui/stepper

Code

stepper-center.tsx
import { Stepper } from "@/components/ui/stepper";

const STEPS = [
  { title: "Account" },
  { title: "Billing" },
  { title: "Confirm" },
];

export default function Example() {
  return (
    <Stepper steps={STEPS} current={1} label="center" />
  );
}

Usage

import { Stepper } from "@/components/ui/stepper";

const STEPS = [
  { title: "Account" },
  { title: "Billing" },
  { title: "Confirm" },
];

export default function Example() {
  return (
    <Stepper steps={STEPS} current={1} />
  );
}

Props

PropTypeDefault
currentrequirednumber—
label"center" | "row""row"
orientation"horizontal" | "vertical""horizontal"
stepsrequiredreadonly { title: string; optional?: string; }[]—

Takes these and every prop of StepperProps.

Dependencies

npm
class-variance-authority@0.7.1
Installs with it
1 icon

Source

components/ui/stepper.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Check01 } from "@/components/icons/check-01";

import { cn } from "@/lib/utils";

/**
 * Stepper — a linear progress indicator (VELA-30).
 *
 * The design crosses `State` (Active / Inactive / Complete) with `Label`
 * (Row = beside the disc, Center = stacked under it) plus an optional caption
 * per step. Discs are 32px pills: active = primary fill with an 18px semibold
 * H4 numeral, inactive = surface-1 with a disabled numeral, complete = success
 * fill with a white check. The connector between steps is a 1px neutral rule.
 */

const stepperVariants = cva("flex", {
  variants: {
    orientation: {
      horizontal: "flex-row items-start",
      vertical: "flex-col items-start",
    },
    label: {
      row: "items-center",
      center: "items-start",
    },
  },
  defaultVariants: {
    orientation: "horizontal",
    label: "row",
  },
});

export type StepperProps = React.ComponentProps<"ol"> &
  VariantProps<typeof stepperVariants> & {
    /**
     * One entry per step. `current` marks the active index; every step before
     * it renders as complete (success disc + check), every step after as
     * inactive.
     */
    steps: ReadonlyArray<{ title: string; optional?: string }>;
    current: number;
  };

function Stepper({
  className,
  steps,
  current,
  orientation = "horizontal",
  label = "row",
  ...props
}: StepperProps) {
  const resolvedOrientation = orientation ?? "horizontal";
  const resolvedLabel = label ?? "row";

  return (
    <ol
      data-slot="stepper"
      data-orientation={resolvedOrientation}
      data-label={resolvedLabel}
      className={cn(
        stepperVariants({ orientation, label }),
        "m-0 list-none p-0",
        className,
      )}
      {...props}
    >
      {steps.map((step, index) => {
        const state = index === current ? "active" : index < current ? "complete" : "inactive";
        return (
          <StepperItem
            key={`${index}-${step.title}`}
            state={state}
            stepNumber={index + 1}
            title={step.title}
            optional={step.optional}
            orientation={resolvedOrientation}
            label={resolvedLabel}
          />
        );
      })}
    </ol>
  );
}

const stepperItemVariants = cva("flex", {
  variants: {
    orientation: {
      horizontal: "flex-row items-start",
      vertical: "flex-col items-stretch",
    },
  },
  defaultVariants: {
    orientation: "horizontal",
  },
});

const stepperDiscVariants = cva(
  "relative flex size-8 shrink-0 items-center justify-center rounded-full text-center text-lg leading-6 font-semibold tracking-[-0.18px] select-none",
  {
    variants: {
      state: {
        active: "bg-primary-default text-text-inverse",
        inactive: "bg-bg-surface-1 text-text-disabled",
        complete: "bg-success-middle text-base-white",
      },
    },
    defaultVariants: {
      state: "inactive",
    },
  },
);

type StepperItemProps = {
  state: "active" | "inactive" | "complete";
  stepNumber: number;
  title: string;
  optional?: string;
  orientation: "horizontal" | "vertical";
  label: "row" | "center";
};

function StepperItem({
  state,
  stepNumber,
  title,
  optional,
  orientation,
  label,
}: StepperItemProps) {
  return (
    <li
      data-slot="stepper-item"
      data-state={state}
      data-orientation={orientation}
      data-label={label}
      className={cn(
        stepperItemVariants({ orientation }),
        "relative",
        // Connector: flex-1 rule between items (horizontal = right of every
        // item except the last; vertical = below, except the last).
        orientation === "horizontal" &&
          "not-last:after:mt-4 not-last:after:h-px not-last:after:flex-1 not-last:after:bg-border-default",
        orientation === "vertical" &&
          "not-last:after:ml-4 not-last:after:w-px not-last:after:self-stretch not-last:after:bg-border-default",
      )}
    >
      <div
        className={cn(
          "flex",
          orientation === "horizontal" && "items-center",
          label === "center" && "flex-col items-center",
          label === "row" && "gap-3",
          label === "center" && orientation === "vertical" && "items-start",
        )}
      >
        <span data-slot="stepper-disc" className={stepperDiscVariants({ state })}>
          {state === "complete" ? (
            <Check01 className="size-4" />
          ) : (
            stepNumber
          )}
        </span>
        <span
          className={cn(
            "flex flex-col",
            label === "row" ? "gap-0.5" : "gap-0.5 text-center",
          )}
        >
          <span
            data-slot="stepper-title"
            className={cn(
              "text-base leading-[1.4] font-semibold text-text-strong",
              state === "inactive" && "text-text-note",
            )}
          >
            {title}
          </span>
          {optional && (
            <span
              data-slot="stepper-optional"
              className="text-xs leading-4 text-text-note"
            >
              {optional}
            </span>
          )}
        </span>
      </div>
    </li>
  );
}

export { Stepper, stepperVariants };