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(),
)
Navigating with currentStep
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.