Skip to content
Blog

Wizard Flows with the Stepper Widget in Flutter

Build multi-step wizards with Flutter's Stepper: Step states, clamped currentStep navigation, controlsBuilder buttons, per-step form validation, and the content scroll gotcha.

Published on • November 2, 2026

AI Assistant

Onboarding, checkout, and setup flows need visible progress plus validation at every stage; Flutter’s Stepper renders the indicators, content, and navigation controls as one declarative widget (docs.flutter.dev).

Steps and Their States

Each Step takes a title, a required content widget, an isActive flag, and a state from StepState — indexed (the default, shows the number), editing, complete, disabled, and error. Drive state from your validation results:

Step(
  title: const Text('Account'),
  isActive: _currentStep >= 0,
  state: _accountValid ? StepState.complete : StepState.error,
  content: _accountForm(),
)

currentStep must stay inside 0..steps.length - 1 (the constructor asserts it), so onStepContinue and onStepCancel clamp their updates — and onStepTapped should only jump back to already-completed steps:

void _continue() {
  if (!_validateCurrentStep()) return; // per-step validation
  setState(() => _currentStep =
      _currentStep < _steps.length - 1 ? _currentStep + 1 : _currentStep);
}

void _cancel() => setState(
    () => _currentStep = _currentStep > 0 ? _currentStep - 1 : 0);

Layout Type and Custom Controls

StepperType.vertical (the default) stacks indicators with content in between; StepperType.horizontal shows a header row with the current step’s content underneath. Replace the default Continue/Cancel pair with controlsBuilder:

controlsBuilder: (context, details) => Row(
  children: [
    FilledButton(
      onPressed: details.onStepContinue,
      child: Text(
          details.currentStep == _steps.length - 1 ? 'Finish' : 'Next'),
    ),
    TextButton(onPressed: details.onStepCancel, child: const Text('Back')),
  ],
),

Inside the builder, details.stepIndex is the step being drawn while details.currentStep is the active one — details.isActive tells them apart mid-transition, and a null onStepContinue disables that button.

Per-Step Validation

Keep one GlobalKey<FormState> per step, validate before advancing, and surface failures as StepState.error:

final _formKeys = [for (final _ in _steps) GlobalKey<FormState>()];

bool _validateCurrentStep() =>
    _formKeys[_currentStep].currentState?.validate() ?? false;

The Scroll Gotcha

Stepper scrolls its own viewport to the newly active step (Scrollable.ensureVisible fires on tap in vertical mode), so a bare Column of fields inside Step.content either overflows the bounded space or jumps back to the top mid-edit. Wrap the content in SingleChildScrollView, or host the wizard in your own scroll view with physics: const ClampingScrollPhysics().

When to Build a Custom Wizard

When you need full-screen steps, custom transitions, cross-step data dependencies, or per-screen analytics, a PageView plus progress bar beats Stepper — the widget optimizes for compact, bounded forms, not choreography.

Drive currentStep from validated state and the Stepper turns a plain form into a wizard without navigation boilerplate.