# FormStepper

> Numbered multi-step wizard. Progress header + render-prop body. Caller owns the current step; completed steps are backward-navigable, upcoming steps are gated.

- Category: form
- Status: stable (since 1.0.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/
- Tokens: --background-tertiary, --background-quaternary, --foreground-primary, --foreground-secondary, --success-color
- Playground: https://design.freecodecamp.org/playground#form-stepper
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `FormStepper.tsx` → `src/ui/form-stepper/FormStepper.tsx` (raw: https://design.freecodecamp.org/registry/form-stepper/FormStepper.tsx)
  - `form-stepper.css` → `src/ui/form-stepper/form-stepper.css` (raw: https://design.freecodecamp.org/registry/form-stepper/form-stepper.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/form-stepper/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/form-stepper/form-stepper.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

Use FormStepper to chunk long forms into ≤ 5 focused screens with
progress. Each step resolves to a render-prop body - the stepper does
not own the form state, just the navigation contract.

## Usage

```tsx
import { FormStepper } from './ui/form-stepper/FormStepper';
const steps = [
  { id: 'intro', label: 'Intro' },
  { id: 'details', label: 'Details', description: 'Name, email' },
  { id: 'confirm', label: 'Confirm' }
];

<FormStepper steps={steps} current={step} onStepChange={setStep}>
  {current =>
    current.id === 'intro' ? (
      <IntroForm />
    ) : current.id === 'details' ? (
      <DetailsForm />
    ) : (
      <ConfirmScreen />
    )
  }
</FormStepper>;
```

## Accessibility

The progress list renders as `<ol>` with `aria-label="Progress"`. The
active step carries `aria-current="step"`. Upcoming steps set
`disabled` on their button; assistive tech skips them in the tab ring.
Pair each step body with its own heading so screen readers announce
the transition.

## Example

```tsx
import { FormStepper } from './ui/form-stepper/FormStepper';
import { useState } from 'react';

const STEPS = [
  { id: 'account',  label: 'Account',  description: 'Email + handle' },
  { id: 'goals',    label: 'Goals',    description: 'What to learn first' },
  { id: 'confirm',  label: 'Confirm',  description: 'Review + start' }
];

const [current, setCurrent] = useState('account');

<FormStepper steps={STEPS} current={current} onStepChange={setCurrent} />
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `steps` | `readonly FormStepperStep[]` | yes | - |  |
| `current` | `string` | yes | - |  |
| `onStepChange` | `(id: string) => void` | yes | - |  |
| `className` | `string` | no | `` |  |
| `isStepAccessible` | `((step: FormStepperStep, state: StepState) => boolean)` | no | - | Override step gating. Defaults to array-index comparison. |
| `ariaLabel` | `string` | no | `Progress` |  |

## Source: FormStepper.tsx

```tsx
import React from 'react';

export interface FormStepperStep {
  id: string;
  label: React.ReactNode;
  description?: React.ReactNode;
}

type StepState = 'complete' | 'current' | 'upcoming';

export interface FormStepperProps {
  steps: readonly FormStepperStep[];
  current: string;
  onStepChange: (id: string) => void;
  children?: (step: FormStepperStep) => React.ReactNode;
  className?: string;
  /** Override step gating. Defaults to array-index comparison. */
  isStepAccessible?: (step: FormStepperStep, state: StepState) => boolean;
  ariaLabel?: string;
}

export const FormStepper = ({
  steps,
  current,
  onStepChange,
  children,
  className = '',
  isStepAccessible,
  ariaLabel = 'Progress'
}: FormStepperProps): React.ReactElement => {
  const currentIndex = Math.max(
    0,
    steps.findIndex(s => s.id === current)
  );
  const resolved = steps[currentIndex] ?? steps[0];
  const classes = ['form-stepper', className].filter(Boolean).join(' ');
  const stateAt = (i: number): StepState => {
    if (i < currentIndex) return 'complete';
    if (i === currentIndex) return 'current';
    return 'upcoming';
  };
  const accessible = (step: FormStepperStep, state: StepState): boolean => {
    if (isStepAccessible) return isStepAccessible(step, state);
    // Default: complete + current are navigable; upcoming gated.
    return state !== 'upcoming';
  };

  return (
    <div className={classes}>
      <ol className='form-stepper__progress' aria-label={ariaLabel}>
        {steps.map((step, i) => {
          const state = stateAt(i);
          const canJump = accessible(step, state);
          return (
            <li key={step.id} className='form-stepper__step' data-state={state}>
              <button
                type='button'
                className='form-stepper__step-btn'
                aria-current={state === 'current' ? 'step' : undefined}
                disabled={!canJump}
                onClick={() => onStepChange(step.id)}
              >
                <span className='form-stepper__step-index' aria-hidden='true'>
                  {i + 1}
                </span>
                <span className='form-stepper__step-text'>
                  <span className='form-stepper__step-label'>{step.label}</span>
                  {step.description !== undefined && (
                    <span className='form-stepper__step-description'>
                      {step.description}
                    </span>
                  )}
                </span>
              </button>
            </li>
          );
        })}
      </ol>
      {resolved !== undefined && (
        <div className='form-stepper__body'>{children?.(resolved)}</div>
      )}
    </div>
  );
};
FormStepper.displayName = 'FormStepper';
```

## Source: form-stepper.css

```css
.form-stepper {
  display: flex;
  flex-direction: column;
  gap: 24px;
}
.form-stepper__progress {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  gap: 8px;
  border: var(--border-width-thin) solid var(--foreground-secondary);
  background: var(--background-tertiary);
  padding: 4px;
}
.form-stepper__step {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
}
.form-stepper__step-btn {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  background: transparent;
  border: 0;
  text-align: left;
  color: var(--foreground-secondary);
  cursor: pointer;
  font-family: var(--font-body);
}
.form-stepper__step[data-state='current'] .form-stepper__step-btn,
.form-stepper__step[data-state='complete'] .form-stepper__step-btn {
  color: var(--foreground-primary);
}
.form-stepper__step[data-state='current'] .form-stepper__step-btn {
  background: var(--background-quaternary);
}
.form-stepper__step-btn:disabled {
  cursor: not-allowed;
  opacity: 0.7;
}
.form-stepper__step-index {
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  border: var(--border-width-thin) solid var(--foreground-secondary);
  color: var(--foreground-secondary);
}
.form-stepper__step[data-state='current'] .form-stepper__step-index {
  color: var(--foreground-primary);
  border-color: var(--foreground-primary);
}
.form-stepper__step[data-state='complete'] .form-stepper__step-index {
  background: var(--success-color);
  border-color: var(--success-color);
  color: var(--background-primary);
}
.form-stepper__step-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.form-stepper__step-label {
  font-weight: 600;
  font-size: var(--fs-sm);
  line-height: 1.3;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.form-stepper__step-description {
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  line-height: 1.4;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.form-stepper__body {
  min-width: 0;
}
```

## HTML / vanilla variant

```html
<ol class="form-stepper">
  <li class="form-stepper__step" aria-current="step">…</li>
</ol>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
