# 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"`
- **ARIA aria-current:** <https://www.w3.org/TR/wai-aria-1.2/#aria-current>
- **Page:** <https://ui.booleanpress.com/components/stepper> · @booleanpress/ui 0.2.0

## 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](/components/pagination); for views of one thing, [tabs](/components/tabs).

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |
| Tab | Moves to the next step that can be chosen, then into the content. |
| Enter or Space | Goes 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. |
| Home or End | Moves to the first or last step that can be chosen. |

## API

### Stepper

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `number` | `1` | The step it starts on, when it controls itself. `1` by default. |
| `linear` | `boolean` | `false` | Steps 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"` | `horizontal` | `horizontal` (default): steps in a row, content below; `vertical`: steps in a column, each step's content under it. |
| `value` | `number` |  | The 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `step` (required) | `number` |  | The step's number, counting from 1. Required. |
| `completed` | `boolean` |  | Marks the step done: a check in its circle. By default every step before the current one is done. |
| `disabled` | `boolean` | `false` | The step's button cannot be used. |
| `error` | `boolean` | `false` | Marks 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `forceMount` | `boolean` | `false` | Keep the content in the page while its step is not current, hidden. |
| `step` | `number` |  | The step whose content this is. Inside a StepperItem (vertical), that item's step by default. |

### StepperPrevious

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `loading` | `boolean` |  | Shows 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. |
| `raised` | `boolean \| null` |  |  |
| `rounded` | `boolean \| 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` | `secondary` | Button's look; `secondary` by default. |

### StepperNext

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `loading` | `boolean` |  | Shows 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. |
| `raised` | `boolean \| null` |  |  |
| `rounded` | `boolean \| 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`.

| Token | Used for |
| --- | --- |
| `--border` | border, background |
| `--card` | background |
| `--card-foreground` | text |
| `--destructive-strong` | text |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--primary` | text, background |
| `--ring` | outline |
