Skip to the content

ComponentsPanel

Stepper

Guides people through a task in numbered steps, showing where they are and what is done.

Import

import { Stepper, StepperList, StepperItem, StepperTrigger, StepperIndicator, StepperTitle, StepperDescription, StepperSeparator, StepperContent, StepperPrevious, StepperNext } from "@booleanpress/ui/stepper"

Usage

A stepper splits a task into steps shown as an ordered list: connecting a mailer, verifying a domain. Each step is a button that goes to it, the current one is marked, and the steps before it show a check. For pages of the same list, use pagination; for views of one thing, tabs.

import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperList, StepperNext, StepperPrevious, StepperSeparator, StepperTitle, StepperTrigger } from "@booleanpress/ui/stepper"

export function Setup() {
  return (
    <Stepper defaultValue={1}>
      <StepperList>
        <StepperItem step={1}>
          <StepperTrigger><StepperIndicator /><StepperTitle>Connection</StepperTitle></StepperTrigger>
          <StepperSeparator />
        </StepperItem>
        <StepperItem step={2}>
          <StepperTrigger><StepperIndicator /><StepperTitle>Sender</StepperTitle></StepperTrigger>
        </StepperItem>
      </StepperList>
      <StepperContent step={1}>Host and port</StepperContent>
      <StepperContent step={2}>From name and address</StepperContent>
      <StepperPrevious />
      <StepperNext />
    </Stepper>
  )
}

The value is the current step's number, counting from 1: uncontrolled with defaultValue, or controlled with value and onValueChange. Every StepperItem takes its step; completed marks it done (by default every step before the current one is), error marks it as needing attention, disabled turns its button off. linear stops people choosing a step after the current one from the list: they move on with StepperNext. StepperNext and StepperPrevious are buttons that move one step, labelled from the provider's next, back and, on the last step, finish strings; call event.preventDefault() in onClick to keep people on a step whose fields are not valid yet. On the last step StepperNext only calls your onClick.

orientation="vertical" stacks the steps; put each step's StepperContent inside its StepperItem, after the separator, where it takes the item's step. Leave out StepperContent for a list of steps alone. StepperIndicator shows the number, a check when done and a cross on error; children replace them.

Examples

Horizontal

Three steps in a row, the current step's content below them.

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

const STEPS = ["Connection", "Sender", "Test email"]

export default function StepperHorizontal() {
  return (
    <Stepper defaultValue={1} className="w-full max-w-2xl">
      <StepperList>
        {STEPS.map((title, index) => (
          <StepperItem key={title} step={index + 1}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
      {STEPS.map((title, index) => (
        <StepperContent key={title} step={index + 1}>
          <div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
            {title} settings
          </div>
        </StepperContent>
      ))}
    </Stepper>
  )
}

Vertical

orientation="vertical": each step's content opens under it, beside the line.

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

const STEPS = ["Connection", "Sender", "Test email"]

export default function StepperVertical() {
  return (
    <Stepper orientation="vertical" defaultValue={1} className="w-full max-w-2xl">
      <StepperList>
        {STEPS.map((title, index) => (
          <StepperItem key={title} step={index + 1}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
            {/* In a vertical stepper each step's content sits in its own item, beside the line. */}
            <StepperContent>
              <div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
                {title} settings
              </div>
            </StepperContent>
          </StepperItem>
        ))}
      </StepperList>
    </Stepper>
  )
}

Linear

linear: later steps cannot be chosen from the list; Back and Next move one step.

import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperNext,
  StepperPrevious,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = ["Connection", "Sender", "Test email"]

export default function StepperLinear() {
  return (
    <Stepper linear defaultValue={1} className="w-full max-w-2xl">
      <StepperList>
        {STEPS.map((title, index) => (
          <StepperItem key={title} step={index + 1}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
      {STEPS.map((title, index) => (
        <StepperContent key={title} step={index + 1}>
          <div className="flex h-48 items-center justify-center rounded-sm border-2 border-dashed bg-subtle font-medium dark:bg-background">
            {title} settings
          </div>
        </StepperContent>
      ))}
      <div className="flex justify-between px-1.5">
        <StepperPrevious />
        <StepperNext />
      </div>
    </Stepper>
  )
}

Steps only

The list without content panels, as a ticket's status.

import {
  Stepper,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = ["Received", "In progress", "Resolved"]

export default function StepperStepsOnly() {
  return (
    <Stepper defaultValue={1} className="w-full max-w-xl">
      <StepperList aria-label="Ticket status">
        {STEPS.map((title, index) => (
          <StepperItem key={title} step={index + 1}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
    </Stepper>
  )
}

Custom indicator

Icons in larger circles replace the numbers, the current one filled; the titles are visually hidden but still name the steps.

import { AtSignIcon, SendIcon, ServerIcon } from "lucide-react"
import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperNext,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = [
  { title: "Connection", icon: ServerIcon, text: "Choose the mail server and its port." },
  { title: "Sender", icon: AtSignIcon, text: "Set the name and address emails come from." },
  { title: "Test email", icon: SendIcon, text: "Send a test to check that mail arrives." },
]

export default function StepperCustomIndicator() {
  return (
    <Stepper defaultValue={1} className="w-full max-w-xl">
      <StepperList aria-label="Mailer setup">
        {STEPS.map((step, index) => (
          <StepperItem key={step.title} step={index + 1}>
            <StepperTrigger>
              {/* Children replace the number; the current step's circle fills with the primary colour. */}
              <StepperIndicator className="size-12 data-[state=active]:border-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
                <step.icon />
              </StepperIndicator>
              <StepperTitle className="sr-only">{step.title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
      {STEPS.map((step, index) => (
        <StepperContent key={step.title} step={index + 1} className="flex flex-col items-center gap-4 text-center">
          <h4 className="text-xl font-semibold">{step.title}</h4>
          <p className="text-muted-foreground">{step.text}</p>
          <StepperNext className="self-end" />
        </StepperContent>
      ))}
    </Stepper>
  )
}

With descriptions

A line of detail under each title; the step before the current one shows a check.

import {
  Stepper,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = [
  { title: "Domain", description: "Add example.com" },
  { title: "DNS records", description: "SPF, DKIM and DMARC" },
  { title: "Verify", description: "Usually under an hour" },
]

export default function StepperWithDescriptions() {
  return (
    <Stepper defaultValue={2} className="w-full max-w-2xl">
      <StepperList aria-label="Domain setup">
        {STEPS.map((step, index) => (
          <StepperItem key={step.title} step={index + 1}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{step.title}</StepperTitle>
              <StepperDescription>{step.description}</StepperDescription>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
    </Stepper>
  )
}

Error state

error draws a red cross and title on the step that needs attention.

import {
  Stepper,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = [
  { title: "Connection", description: "smtp.example.com:587" },
  { title: "Authentication", description: "535: credentials rejected", error: true },
  { title: "Test email", description: "Not sent yet" },
]

export default function StepperErrorState() {
  return (
    <Stepper defaultValue={2} className="w-full max-w-2xl">
      <StepperList aria-label="Connection check">
        {STEPS.map((step, index) => (
          <StepperItem key={step.title} step={index + 1} error={step.error}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{step.title}</StepperTitle>
              <StepperDescription>{step.description}</StepperDescription>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
    </Stepper>
  )
}

Wizard

Connecting a mailer: Next stays on the API key step and marks it with an error until a key is pasted, then a review ends with Finish.

import { useState } from "react"
import { Field, FieldError, FieldLabel } from "@booleanpress/ui/field"
import { Input } from "@booleanpress/ui/input"
import { RadioGroup, RadioGroupItem } from "@booleanpress/ui/radio-group"
import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperNext,
  StepperPrevious,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@booleanpress/ui/stepper"

const STEPS = ["Provider", "API key", "Review"]
const PROVIDERS = ["Amazon SES", "Mailgun", "Postmark"]

export default function StepperWizard() {
  const [step, setStep] = useState(1)
  const [provider, setProvider] = useState("Mailgun")
  const [apiKey, setApiKey] = useState("")
  const [missingKey, setMissingKey] = useState(false)
  const [connected, setConnected] = useState(false)

  return (
    <Stepper linear value={step} onValueChange={setStep} className="w-full max-w-xl">
      <StepperList aria-label="Connect a mailer">
        {STEPS.map((title, index) => (
          <StepperItem key={title} step={index + 1} error={index === 1 && missingKey}>
            <StepperTrigger>
              <StepperIndicator />
              <StepperTitle>{title}</StepperTitle>
            </StepperTrigger>
            {index < STEPS.length - 1 ? <StepperSeparator /> : null}
          </StepperItem>
        ))}
      </StepperList>
      <StepperContent step={1}>
        <RadioGroup aria-label="Provider" value={provider} onValueChange={setProvider}>
          {PROVIDERS.map((name) => (
            <Field key={name} orientation="horizontal">
              <RadioGroupItem id={`provider-${name}`} value={name} />
              <FieldLabel htmlFor={`provider-${name}`}>{name}</FieldLabel>
            </Field>
          ))}
        </RadioGroup>
      </StepperContent>
      <StepperContent step={2}>
        <Field data-invalid={missingKey || undefined}>
          <FieldLabel htmlFor="wizard-key">{provider} API key</FieldLabel>
          <Input
            id="wizard-key"
            value={apiKey}
            aria-invalid={missingKey || undefined}
            aria-describedby={missingKey ? "wizard-key-error" : undefined}
            onChange={(event) => setApiKey(event.target.value)}
          />
          {missingKey ? <FieldError id="wizard-key-error">Paste the key to continue.</FieldError> : null}
        </Field>
      </StepperContent>
      <StepperContent step={3}>
        <p>{connected ? `${provider} is connected.` : `Connect ${provider} with the key ending ${apiKey.slice(-4)}?`}</p>
      </StepperContent>
      <div className="flex justify-between px-1.5">
        <StepperPrevious />
        <StepperNext
          disabled={connected}
          onClick={(event) => {
            const blocked = step === 2 && apiKey.trim() === ""
            setMissingKey(blocked)
            if (blocked) event.preventDefault()
            if (step === 3) setConnected(true)
          }}
        />
      </div>
    </Stepper>
  )
}

Accessibility

Semantics
The steps are an ordered list (ol); each step is a button, and the current one has aria-current="step". A step's content is a group named by its step's button. Steps a linear stepper blocks are disabled buttons.
Labels
Each button is named in words, from the provider's strings: "Step 2 of 3", the title and description, then "Completed" or "Has errors". The drawn number, check and cross are hidden from assistive technology. Name the list with aria-label when the page has more than one.
Focus
Every step that can be chosen is a tab stop, in order; the arrow keys also move between them. Choosing a step keeps focus on its button. When Back disables on the first step while it has focus, focus moves to Next (and from a Next you disable to Back), so it never falls to the page. Focus is a 1px --ring outline 2px outside the button.
Known limits
  • A step's content is not announced when it changes: move focus into it yourself if the new step needs it, for example to its first field.
  • Disabled steps are out of the tab order; their titles are still read in the list.
  • Titles truncate with an ellipsis when the row is narrow; the full title stays in the button's name.

Keyboard

Keyboard
KeyBehaviour
TabMoves to the next step that can be chosen, then into the content.
EnterorSpaceGoes to the focused step.
โ†’orโ†Moves to the next or previous step, wrapping at the ends (โ†“ and โ†‘ when vertical). Reversed in a right-to-left page.
HomeorEndMoves to the first or last step that can be chosen.

API

Stepper

Renders a div and passes it every other prop.

Stepper props
PropTypeDefaultDescription
defaultValuenumber1The step it starts on, when it controls itself. 1 by default.
linearbooleanfalseSteps after the current one cannot be chosen from the list: StepperNext (after your checks) moves on.
onValueChange((step: number) => void)Called with the step's number when a step's button, StepperPrevious or StepperNext changes the step.
orientation"horizontal" | "vertical"horizontalhorizontal (default): steps in a row, content below; vertical: steps in a column, each step's content under it.
valuenumberThe current step's number, when you control it. Pair it with onValueChange.

StepperList

Renders a ol and passes it every other prop.

StepperItem

Renders a li and passes it every other prop.

StepperItem props
PropTypeDefaultDescription
steprequirednumberThe step's number, counting from 1. Required.
completedbooleanMarks the step done: a check in its circle. By default every step before the current one is done.
disabledbooleanfalseThe step's button cannot be used.
errorbooleanfalseMarks the step as needing attention: a cross in a red circle, the title in red.

StepperTrigger

Renders a button and passes it every other prop.

StepperIndicator

Renders a span and passes it every other prop.

StepperTitle

Renders a span and passes it every other prop.

StepperDescription

Renders a span and passes it every other prop.

StepperSeparator

Renders a span and passes it every other prop.

StepperContent

Renders a div and passes it every other prop.

StepperContent props
PropTypeDefaultDescription
forceMountbooleanfalseKeep the content in the page while its step is not current, hidden.
stepnumberThe step whose content this is. Inside a StepperItem (vertical), that item's step by default.

StepperPrevious

StepperPrevious props
PropTypeDefaultDescription
asChildboolean
loadingbooleanShows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click, Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With asChild the child (a link) gets the same.
raisedboolean | null
roundedboolean | null
severity"success" | "info" | "warning" | "help" | "danger" | "contrast" | null
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | nullsecondaryButton's look; secondary by default.

StepperNext

StepperNext props
PropTypeDefaultDescription
asChildboolean
loadingbooleanShows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click, Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With asChild the child (a link) gets the same.
raisedboolean | null
roundedboolean | null
severity"success" | "info" | "warning" | "help" | "danger" | "contrast" | null
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="stepper" (Stepper), data-slot="stepper-trigger" (StepperList), data-slot="stepper-item" (StepperItem), data-slot="stepper-trigger" (StepperTrigger), data-slot="stepper-indicator" (StepperIndicator), data-slot="stepper-title" (StepperTitle), data-slot="stepper-description" (StepperDescription), data-slot="stepper-separator" (StepperSeparator), data-slot="stepper-content" (StepperContent), data-slot="stepper-previous" (StepperPrevious), data-slot="stepper-next" (StepperNext), and data-orientation, data-linear, data-state, data-error, data-disabled, data-last.

Provider strings: stepOf, stepError, stepCompleted, back, finish, next (BooleanUIProvider's strings).

Theming

The circle is --card with a 2px --border edge; its number is --muted-foreground, the current step's and the check --primary; an error is --invalid and --destructive-strong. Titles are 14px medium in --muted-foreground, the current one --primary. The line after a completed step is --primary, the others --border.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--borderborder, background
--cardbackground
--card-foregroundtext
--destructive-strongtext
--invalidborder
--muted-foregroundtext
--primarytext, background
--ringoutline