UI Elements

Stepper

Composable step indicator with list, item, trigger, indicator, title, separator and content parts. Choose numbered steps or progress bars and one of four rail inks; navigation is linear by default.

Free, MIT licenseOpen in Browse
"use client"

import * as React from "react"
import { cn } from "cn"

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

type StepperState = "completed" | "active" | "inactive"

// The done rail's ink. soft (foreground at 40%) is the default: the rail
// connects and shows progress while the indicators stay the strongest marks.
// accent paints the rail, the done indicators and the current ring with
// --stepper-accent; set it on the Stepper or above it, and pair it with
// --stepper-accent-foreground (default white) when the accent is light.
type StepperRail = "soft" | "ink" | "muted" | "accent"

// steps: indicators joined by a 1px rail. bars: no indicators, one rounded
// 4px segment per step, filled through the current one; a block renders only
// StepperSeparator and an sr-only StepperTitle in each item.
type StepperVariant = "steps" | "bars"

type StepperContextValue = {
  value: number
  setValue: (value: number) => void
  linear: boolean
  variant: StepperVariant
  rail: StepperRail
  current: boolean
}

type StepperItemContextValue = {
  value: number
  state: StepperState
  disabled: boolean
}

const StepperContext = React.createContext<StepperContextValue | null>(null)
const StepperItemContext = React.createContext<StepperItemContextValue | null>(
  null
)

// Inside a Stepper, a Next or Back button calls setValue; in linear mode the
// triggers cannot reach a step ahead, so this is how a workflow advances.
function useStepper() {
  const ctx = React.useContext(StepperContext)
  if (!ctx) throw new Error("Stepper parts must be used within <Stepper>")
  return ctx
}

function useStepperItem() {
  const ctx = React.useContext(StepperItemContext)
  if (!ctx) throw new Error("Stepper parts must be used within <StepperItem>")
  return ctx
}

/**
 * A set of steps, each with an optional panel, for a multi-step form or a
 * progress count. Holds a `StepperList` of `StepperItem` steps and any
 * `StepperContent` panels. Set the selected step with `value` and
 * `onValueChange`, or let it manage its own from `defaultValue`.
 *
 * It shows position, not progress: a step before the selected one reads as
 * completed and a step after it as inactive, whichever way the user moved.
 * Track real completion yourself if a form needs it. Next and Back buttons
 * change the step with `setValue` from `useStepper`. Each `StepperContent`
 * mounts only while its step is selected, unless it has `keepMounted`.
 */
function Stepper({
  className,
  value,
  defaultValue = 0,
  onValueChange,
  // Knob: false lets a step ahead of the current one be selected.
  linear = true,
  rail = "soft",
  variant = "steps",
  // false for a read-only count (3 of 5 done) that has no current step to
  // announce; bars then carry no aria-current.
  current = true,
  ...props
}: React.ComponentProps<"div"> & {
  /** The selected step, when you control it with `onValueChange`. */
  value?: number
  /** The first selected step when `value` is not set. Defaults to 0. */
  defaultValue?: number
  /** Called with the new step when a trigger or `setValue` changes it. */
  onValueChange?: (value: number) => void
  /**
   * While true (the default), steps after the selected one are disabled, so
   * triggers reach only the selected step and earlier ones. Set false to let
   * any step be selected.
   */
  linear?: boolean
  /**
   * The color of the filled rail. `soft` (the default) is the text color at
   * 40%, `ink` the full text color, `muted` the muted text color. In `bars`,
   * `soft` fills with the full text color. `accent` paints the rail, the
   * completed indicators and the selected step's ring with `--stepper-accent`;
   * set it on the `Stepper` or above it, with `--stepper-accent-foreground`
   * (white by default) for text on the accent.
   */
  rail?: StepperRail
  /**
   * `steps` (the default) joins the indicators with a 1px rail. `bars` turns
   * each `StepperSeparator` into a 4px rounded segment, filled through the
   * selected step; give each item only a separator and a visually hidden
   * `StepperTitle`.
   */
  variant?: StepperVariant
  /**
   * In `bars`, while true (the default), the selected item carries
   * `aria-current="step"`. Set false for a read-only count, such as 3 of 5
   * done, that has no current step to announce.
   */
  current?: boolean
}) {
  const [internal, setInternal] = React.useState(defaultValue)
  const current_ = value ?? internal

  const setValue = React.useCallback(
    (next: number) => {
      if (value === undefined) setInternal(next)
      onValueChange?.(next)
    },
    [value, onValueChange]
  )

  const ctx = React.useMemo(
    () => ({ value: current_, setValue, linear, variant, rail, current }),
    [current_, setValue, linear, variant, rail, current]
  )

  return (
    <StepperContext value={ctx}>
      <div
        data-slot="stepper"
        data-rail={rail}
        data-variant={variant}
        className={cn("group/stepper flex w-full flex-col gap-6", className)}
        {...props}
      />
    </StepperContext>
  )
}

/** The ordered list that holds the `StepperItem` steps, in one row. */
function StepperList({ className, ...props }: React.ComponentProps<"ol">) {
  return (
    <ol
      role="list"
      data-slot="stepper-list"
      className={cn(
        "flex w-full items-center group-data-[variant=bars]/stepper:gap-1.5",
        className
      )}
      {...props}
    />
  )
}

/**
 * One step, inside `StepperList`. It works out its state from its `value`
 * (`completed`, `active` or `inactive`) and sets it as `data-state` for its
 * parts and your styles. Put a `StepperTrigger` and a `StepperSeparator`
 * inside it, or only a separator and a title in the `bars` variant.
 *
 * Items share the row's width, except the last in `steps`, which is only as
 * wide as its content.
 */
function StepperItem({
  className,
  value,
  disabled,
  ...props
}: React.ComponentProps<"li"> & {
  /** This step's number, compared with the `Stepper` value to set its state. */
  value: number
  /** Disables this step's trigger. Linear mode disables later steps anyway. */
  disabled?: boolean
}) {
  const stepper = useStepper()
  const state: StepperState =
    stepper.value === value
      ? "active"
      : stepper.value > value
        ? "completed"
        : "inactive"

  const ctx = React.useMemo(
    () => ({
      value,
      state,
      disabled: disabled || (stepper.linear && state === "inactive"),
    }),
    [value, state, disabled, stepper.linear]
  )

  return (
    <StepperItemContext value={ctx}>
      <li
        data-slot="stepper-item"
        data-state={state}
        // bars have no trigger, so the item names the current step.
        aria-current={
          stepper.variant === "bars" && stepper.current && state === "active"
            ? "step"
            : undefined
        }
        className={cn(
          "group/step flex flex-1 items-center last:flex-none group-data-[variant=bars]/stepper:last:flex-1",
          className
        )}
        {...props}
      />
    </StepperItemContext>
  )
}

/**
 * The button that selects its step, built on the stock `Button` with no
 * padding or fill. Put a `StepperIndicator` and a `StepperTitle` inside it.
 * A click selects the step unless the step is disabled or your `onClick`
 * calls `event.preventDefault()`.
 *
 * The selected step's trigger carries `aria-current="step"`, and a disabled
 * trigger keeps its full opacity. `className` takes a string only.
 */
function StepperTrigger({
  className,
  onClick,
  disabled: disabledProp,
  ...props
}: Omit<
  React.ComponentProps<typeof Button>,
  "className" | "size" | "variant"
> & {
  /**
   * Extra classes, as a string only: the stock `Button` does not accept a
   * `className` function.
   */
  className?: string
  /**
   * The stock `Button` size, usually left unset. The trigger replaces the
   * text sizes' height, padding and gap with its own, so only `xs` changes
   * it, with smaller text and icons.
   */
  size?: React.ComponentProps<typeof Button>["size"]
  /**
   * The stock `Button` variant, `ghost` by default here. The trigger cancels
   * the hover fill whatever the variant, so another variant changes only the
   * resting fill, border and text color.
   */
  variant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const { setValue } = useStepper()
  const { value, state, disabled } = useStepperItem()
  const isDisabled = disabled || Boolean(disabledProp)

  return (
    <Button
      data-slot="stepper-trigger"
      data-state={state}
      variant="ghost"
      aria-current={state === "active" ? "step" : undefined}
      className={cn(
        // No pill: the only clickable step is a completed one, so hover lives
        // on its title and ring, and the indicator sits flush with the content.
        "h-auto gap-3 px-0 py-0 hover:bg-transparent hover:text-inherit dark:hover:bg-transparent",
        "disabled:opacity-100 data-[state=active]:cursor-default",
        "data-[state=completed]:hover:[&_[data-slot=stepper-title]]:text-foreground",
        "data-[state=completed]:hover:[&_[data-slot=stepper-indicator]]:bg-primary/85",
        className
      )}
      disabled={isDisabled}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented && !isDisabled) setValue(value)
      }}
      {...props}
    />
  )
}

/**
 * The round mark for a step, holding its number or an icon you pass. Its
 * fill and ring follow the step's state. It is a `span`, so it is valid
 * inside the trigger's button.
 */
function StepperIndicator({
  className,
  ...props
}: React.ComponentProps<"span">) {
  const { state } = useStepperItem()
  return (
    <span
      data-slot="stepper-indicator"
      data-state={state}
      className={cn(
        "flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-medium transition-colors motion-reduce:transition-none",
        "bg-card text-card-foreground ring-1 ring-border",
        "data-[state=active]:bg-foreground/10 data-[state=active]:text-foreground data-[state=active]:ring-2 data-[state=active]:ring-foreground",
        "data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground",
        "group-data-[rail=accent]/stepper:data-[state=active]:bg-(--stepper-accent)/10 group-data-[rail=accent]/stepper:data-[state=active]:ring-(--stepper-accent)",
        "group-data-[rail=accent]/stepper:data-[state=completed]:bg-(--stepper-accent) group-data-[rail=accent]/stepper:data-[state=completed]:text-(--stepper-accent-foreground,white)",
        className
      )}
      {...props}
    />
  )
}

/**
 * The step's name, in small text. It is muted until the step is selected or
 * completed.
 */
function StepperTitle({ className, ...props }: React.ComponentProps<"span">) {
  const { state } = useStepperItem()
  return (
    <span
      data-slot="stepper-title"
      data-state={state}
      className={cn(
        "text-sm font-medium text-muted-foreground data-[state=active]:text-foreground data-[state=completed]:text-foreground",
        className
      )}
      {...props}
    />
  )
}

/** A line of small muted text about a step, as a `span`. */
function StepperDescription({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="stepper-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

// The done fill per rail (soft is foreground at 40%, see StepperRail). On
// bars the segment is the indicator, so soft reads as full ink there to clear
// 3:1 against the page, and the active segment fills too.
const RAIL_DONE: Record<StepperRail, string> = {
  soft: "data-[state=completed]:bg-foreground/40",
  ink: "data-[state=completed]:bg-foreground",
  muted: "data-[state=completed]:bg-muted-foreground",
  accent: "data-[state=completed]:bg-(--stepper-accent)",
}
const BARS_DONE: Record<StepperRail, string> = {
  soft: "data-[state=completed]:bg-foreground data-[state=active]:bg-foreground",
  ink: "data-[state=completed]:bg-foreground data-[state=active]:bg-foreground",
  muted:
    "data-[state=completed]:bg-muted-foreground data-[state=active]:bg-muted-foreground",
  accent:
    "data-[state=completed]:bg-(--stepper-accent) data-[state=active]:bg-(--stepper-accent)",
}

/**
 * The rail after a step, hidden from assistive tech. In `steps` it is a 1px
 * line to the next step that fills once its step is completed; place it
 * after the `StepperTrigger` in each item except the last. In `bars` it is
 * the step's 4px segment, filled for completed steps and the selected one.
 * The fill color comes from the `Stepper` `rail`.
 */
function StepperSeparator({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const { variant, rail } = useStepper()
  const { state } = useStepperItem()
  return (
    <div
      data-slot="stepper-separator"
      data-state={state}
      aria-hidden="true"
      className={cn(
        "mx-2 h-px flex-1 bg-border transition-colors motion-reduce:transition-none",
        variant === "bars"
          ? cn("mx-0 h-1 rounded-full", BARS_DONE[rail])
          : RAIL_DONE[rail],
        className
      )}
      {...props}
    />
  )
}

/**
 * The panel for one step, shown while that step is selected. Place it inside
 * `Stepper`, usually after `StepperList`.
 */
function StepperContent({
  className,
  value,
  keepMounted = false,
  ...props
}: React.ComponentProps<"div"> & {
  /** The step this panel belongs to. */
  value: number
  /**
   * Keeps the panel mounted but hidden while another step is selected, so its
   * inputs keep their values. Defaults to false.
   */
  keepMounted?: boolean
}) {
  const stepper = useStepper()
  const active = stepper.value === value
  if (!active && !keepMounted) return null
  return (
    <div
      data-slot="stepper-content"
      data-state={active ? "active" : "inactive"}
      hidden={!active}
      className={cn("grid gap-2", className)}
      {...props}
    />
  )
}

export {
  Stepper,
  useStepper,
  StepperList,
  StepperItem,
  StepperTrigger,
  StepperIndicator,
  StepperTitle,
  StepperDescription,
  StepperSeparator,
  StepperContent,
}
export type { StepperRail, StepperState, StepperVariant }

Installation

pnpm dlx shadcn add @01orbital/stepper
npx shadcn add @01orbital/stepper
yarn shadcn add @01orbital/stepper
bunx --bun shadcn add @01orbital/stepper

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/stepper/stepper.tsx, as source you own and edit.

The example also imports @phosphor-icons/react from npm, which the install does not add:

pnpm add @phosphor-icons/react
npm install @phosphor-icons/react
yarn add @phosphor-icons/react
bun add @phosphor-icons/react

Usage

"use client"

import { useEffect, useRef, useState } from "react"
import { CheckIcon } from "@phosphor-icons/react"

import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  back: "Back",
  next: "Continue",
} as const

const STEPS = [
  { title: "Choose", body: "Choose your pieces." },
  { title: "Arrange", body: "Arrange them together." },
  { title: "Review", body: "Review your composition." },
]

export default function StepperDemo() {
  const [step, setStep] = useState(0)
  const panel = useRef<HTMLDivElement>(null)
  const shown = useRef(step)

  // Next and Back can disable under the pointer, so focus moves to the new step.
  useEffect(() => {
    if (shown.current !== step) panel.current?.focus()
    shown.current = step
  }, [step])

  return (
    <Stepper value={step} onValueChange={setStep}>
      <StepperList>
        {STEPS.map((s, i) => (
          <StepperItem key={s.title} value={i}>
            <StepperTrigger>
              <StepperIndicator>
                <CheckIcon
                  weight="bold"
                  aria-hidden="true"
                  className="hidden size-4 group-data-[state=completed]/step:block"
                />
                <span className="group-data-[state=completed]/step:hidden">
                  {i + 1}
                </span>
              </StepperIndicator>
              {/* Kept for screen readers on a phone: it names the step. */}
              <StepperTitle className="sr-only sm:not-sr-only">
                {s.title}
              </StepperTitle>
            </StepperTrigger>
            <StepperSeparator className="group-last/step:hidden" />
          </StepperItem>
        ))}
      </StepperList>

      {STEPS.map((s, i) => (
        <StepperContent
          key={s.title}
          value={i}
          ref={panel}
          tabIndex={-1}
          className="outline-none"
        >
          <p>{s.body}</p>
        </StepperContent>
      ))}

      <div className="flex gap-3">
        <Button
          variant="outline"
          disabled={step === 0}
          onClick={() => setStep(step - 1)}
        >
          {COPY.back}
        </Button>
        <Button
          disabled={step === STEPS.length - 1}
          onClick={() => setStep(step + 1)}
        >
          {COPY.next}
        </Button>
      </div>
    </Stepper>
  )
}

Anatomy

Stepper is built from 9 parts. In the example they nest like this:

Stepper
├─ StepperList
│  └─ StepperItem
│     ├─ StepperTrigger
│     │  ├─ StepperIndicator
│     │  └─ StepperTitle
│     └─ StepperSeparator
└─ StepperContent
  • Stepper: A set of steps, each with an optional panel, for a multi-step form or a progress count. Holds a StepperList of StepperItem steps and any StepperContent panels. Set the selected step with value and onValueChange, or let it manage its own from defaultValue.
  • StepperList: The ordered list that holds the StepperItem steps, in one row.
  • StepperItem: One step, inside StepperList. It works out its state from its value (completed, active or inactive) and sets it as data-state for its parts and your styles. Put a StepperTrigger and a StepperSeparator inside it, or only a separator and a title in the bars variant.
  • StepperTrigger: The button that selects its step, built on the stock Button with no padding or fill. Put a StepperIndicator and a StepperTitle inside it. A click selects the step unless the step is disabled or your onClick calls event.preventDefault().
  • StepperIndicator: The round mark for a step, holding its number or an icon you pass. Its fill and ring follow the step's state. It is a span, so it is valid inside the trigger's button.
  • StepperTitle: The step's name, in small text. It is muted until the step is selected or completed.
  • StepperSeparator: The rail after a step, hidden from assistive tech. In steps it is a 1px line to the next step that fills once its step is completed; place it after the StepperTrigger in each item except the last. In bars it is the step's 4px segment, filled for completed steps and the selected one. The fill color comes from the Stepper rail.
  • StepperContent: The panel for one step, shown while that step is selected. Place it inside Stepper, usually after StepperList.
  • StepperDescription: A line of small muted text about a step, as a span.

API reference

Stepper

A set of steps, each with an optional panel, for a multi-step form or a progress count. Holds a StepperList of StepperItem steps and any StepperContent panels. Set the selected step with value and onValueChange, or let it manage its own from defaultValue.

It shows position, not progress: a step before the selected one reads as completed and a step after it as inactive, whichever way the user moved. Track real completion yourself if a form needs it. Next and Back buttons change the step with setValue from useStepper. Each StepperContent mounts only while its step is selected, unless it has keepMounted.

Prop

Type

It also accepts the HTML attributes of the element it renders.

StepperList

The ordered list that holds the StepperItem steps, in one row.

It accepts the HTML attributes of the element it renders.

StepperItem

One step, inside StepperList. It works out its state from its value (completed, active or inactive) and sets it as data-state for its parts and your styles. Put a StepperTrigger and a StepperSeparator inside it, or only a separator and a title in the bars variant.

Items share the row's width, except the last in steps, which is only as wide as its content.

Prop

Type

It also accepts the HTML attributes of the element it renders.

StepperTrigger

The button that selects its step, built on the stock Button with no padding or fill. Put a StepperIndicator and a StepperTitle inside it. A click selects the step unless the step is disabled or your onClick calls event.preventDefault().

The selected step's trigger carries aria-current="step", and a disabled trigger keeps its full opacity. className takes a string only.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

StepperIndicator

The round mark for a step, holding its number or an icon you pass. Its fill and ring follow the step's state. It is a span, so it is valid inside the trigger's button.

It accepts the HTML attributes of the element it renders.

StepperTitle

The step's name, in small text. It is muted until the step is selected or completed.

It accepts the HTML attributes of the element it renders.

StepperSeparator

The rail after a step, hidden from assistive tech. In steps it is a 1px line to the next step that fills once its step is completed; place it after the StepperTrigger in each item except the last. In bars it is the step's 4px segment, filled for completed steps and the selected one. The fill color comes from the Stepper rail.

It accepts the HTML attributes of the element it renders.

StepperContent

The panel for one step, shown while that step is selected. Place it inside Stepper, usually after StepperList.

Prop

Type

It also accepts the HTML attributes of the element it renders.

StepperDescription

A line of small muted text about a step, as a span.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: cn. The CLI installs them for you.

Registry pieces it installs with it:

  • button