Flutter Stepper Localization: Multi-Step Forms and Wizard UI
Stepper widgets guide users through complex processes like registration, checkout, or onboarding. Proper localization of step labels, instructions, and navigation ensures users worldwide can complete multi-step workflows confidently. This guide covers everything you need to know about localizing steppers in Flutter.
Understanding Stepper Localization
Stepper localization involves more than translating step titles. You need to consider:
- Step labels and titles - Clear, concise step names
- Step content instructions - Detailed guidance per step
- Navigation buttons - Continue, Back, Cancel actions
- Validation messages - Step-specific error feedback
- Progress indicators - "Step X of Y" format
- Completion states - Success and error states
Setting Up Stepper Localization
ARB File Structure
{
"@@locale": "en",
"stepperProgress": "Step {current} of {total}",
"@stepperProgress": {
"description": "Progress indicator for stepper",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"stepContinue": "Continue",
"@stepContinue": {
"description": "Continue to next step button"
},
"stepBack": "Back",
"@stepBack": {
"description": "Go back to previous step button"
},
"stepCancel": "Cancel",
"@stepCancel": {
"description": "Cancel stepper process button"
},
"stepComplete": "Complete",
"@stepComplete": {
"description": "Complete/finish stepper button"
},
"stepSkip": "Skip",
"@stepSkip": {
"description": "Skip optional step button"
},
"stepOptional": "Optional",
"@stepOptional": {
"description": "Label for optional steps"
},
"registrationStepAccount": "Account",
"@registrationStepAccount": {
"description": "Account creation step title"
},
"registrationStepAccountSubtitle": "Create your login credentials",
"@registrationStepAccountSubtitle": {
"description": "Account step subtitle"
},
"registrationStepProfile": "Profile",
"@registrationStepProfile": {
"description": "Profile setup step title"
},
"registrationStepProfileSubtitle": "Tell us about yourself",
"@registrationStepProfileSubtitle": {
"description": "Profile step subtitle"
},
"registrationStepPreferences": "Preferences",
"@registrationStepPreferences": {
"description": "Preferences step title"
},
"registrationStepPreferencesSubtitle": "Customize your experience",
"@registrationStepPreferencesSubtitle": {
"description": "Preferences step subtitle"
},
"registrationStepConfirmation": "Confirmation",
"@registrationStepConfirmation": {
"description": "Final confirmation step title"
},
"registrationStepConfirmationSubtitle": "Review and submit",
"@registrationStepConfirmationSubtitle": {
"description": "Confirmation step subtitle"
},
"stepCompletedMessage": "Step completed successfully",
"@stepCompletedMessage": {
"description": "Message when step is completed"
},
"stepErrorMessage": "Please fix the errors before continuing",
"@stepErrorMessage": {
"description": "Message when step has errors"
},
"allStepsCompleted": "All steps completed!",
"@allStepsCompleted": {
"description": "Message when all steps are done"
},
"stepValidationRequired": "This step requires completion",
"@stepValidationRequired": {
"description": "Validation message for required step"
}
}
Spanish Translations
{
"@@locale": "es",
"stepperProgress": "Paso {current} de {total}",
"stepContinue": "Continuar",
"stepBack": "Atrás",
"stepCancel": "Cancelar",
"stepComplete": "Completar",
"stepSkip": "Omitir",
"stepOptional": "Opcional",
"registrationStepAccount": "Cuenta",
"registrationStepAccountSubtitle": "Crea tus credenciales de acceso",
"registrationStepProfile": "Perfil",
"registrationStepProfileSubtitle": "Cuéntanos sobre ti",
"registrationStepPreferences": "Preferencias",
"registrationStepPreferencesSubtitle": "Personaliza tu experiencia",
"registrationStepConfirmation": "Confirmación",
"registrationStepConfirmationSubtitle": "Revisa y envía",
"stepCompletedMessage": "Paso completado con éxito",
"stepErrorMessage": "Por favor corrige los errores antes de continuar",
"allStepsCompleted": "¡Todos los pasos completados!",
"stepValidationRequired": "Este paso requiere completarse"
}
Arabic Translations (RTL)
{
"@@locale": "ar",
"stepperProgress": "الخطوة {current} من {total}",
"stepContinue": "متابعة",
"stepBack": "رجوع",
"stepCancel": "إلغاء",
"stepComplete": "إكمال",
"stepSkip": "تخطي",
"stepOptional": "اختياري",
"registrationStepAccount": "الحساب",
"registrationStepAccountSubtitle": "أنشئ بيانات تسجيل الدخول",
"registrationStepProfile": "الملف الشخصي",
"registrationStepProfileSubtitle": "أخبرنا عن نفسك",
"registrationStepPreferences": "التفضيلات",
"registrationStepPreferencesSubtitle": "خصص تجربتك",
"registrationStepConfirmation": "التأكيد",
"registrationStepConfirmationSubtitle": "راجع وأرسل",
"stepCompletedMessage": "تم إكمال الخطوة بنجاح",
"stepErrorMessage": "يرجى إصلاح الأخطاء قبل المتابعة",
"allStepsCompleted": "تم إكمال جميع الخطوات!",
"stepValidationRequired": "هذه الخطوة مطلوبة"
}
Building Localized Stepper Components
Basic Localized Stepper
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedStepper extends StatefulWidget {
const LocalizedStepper({super.key});
@override
State<LocalizedStepper> createState() => _LocalizedStepperState();
}
class _LocalizedStepperState extends State<LocalizedStepper> {
int _currentStep = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stepper(
currentStep: _currentStep,
onStepContinue: _onStepContinue,
onStepCancel: _onStepCancel,
onStepTapped: (step) => setState(() => _currentStep = step),
controlsBuilder: (context, details) {
return _buildControls(context, details, l10n);
},
steps: [
Step(
title: Text(l10n.registrationStepAccount),
subtitle: Text(l10n.registrationStepAccountSubtitle),
content: _buildAccountStep(l10n),
isActive: _currentStep >= 0,
state: _getStepState(0),
),
Step(
title: Text(l10n.registrationStepProfile),
subtitle: Text(l10n.registrationStepProfileSubtitle),
content: _buildProfileStep(l10n),
isActive: _currentStep >= 1,
state: _getStepState(1),
),
Step(
title: Text(l10n.registrationStepPreferences),
subtitle: Text(l10n.registrationStepPreferencesSubtitle),
content: _buildPreferencesStep(l10n),
isActive: _currentStep >= 2,
state: _getStepState(2),
),
Step(
title: Text(l10n.registrationStepConfirmation),
subtitle: Text(l10n.registrationStepConfirmationSubtitle),
content: _buildConfirmationStep(l10n),
isActive: _currentStep >= 3,
state: _getStepState(3),
),
],
);
}
Widget _buildControls(
BuildContext context,
ControlsDetails details,
AppLocalizations l10n,
) {
final isLastStep = _currentStep == 3;
final isFirstStep = _currentStep == 0;
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
children: [
ElevatedButton(
onPressed: details.onStepContinue,
child: Text(isLastStep ? l10n.stepComplete : l10n.stepContinue),
),
const SizedBox(width: 12),
if (!isFirstStep)
TextButton(
onPressed: details.onStepCancel,
child: Text(l10n.stepBack),
),
],
),
);
}
StepState _getStepState(int step) {
if (_currentStep > step) return StepState.complete;
if (_currentStep == step) return StepState.editing;
return StepState.indexed;
}
void _onStepContinue() {
if (_currentStep < 3) {
setState(() => _currentStep++);
} else {
_completeRegistration();
}
}
void _onStepCancel() {
if (_currentStep > 0) {
setState(() => _currentStep--);
}
}
void _completeRegistration() {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.allStepsCompleted)),
);
}
Widget _buildAccountStep(AppLocalizations l10n) {
return const Column(
children: [
TextField(decoration: InputDecoration(labelText: 'Email')),
SizedBox(height: 16),
TextField(
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
),
],
);
}
Widget _buildProfileStep(AppLocalizations l10n) {
return const Column(
children: [
TextField(decoration: InputDecoration(labelText: 'Full Name')),
SizedBox(height: 16),
TextField(decoration: InputDecoration(labelText: 'Phone')),
],
);
}
Widget _buildPreferencesStep(AppLocalizations l10n) {
return const Column(
children: [
SwitchListTile(
title: Text('Email notifications'),
value: true,
onChanged: null,
),
],
);
}
Widget _buildConfirmationStep(AppLocalizations l10n) {
return const Text('Review your information and submit.');
}
}
Stepper with Progress Indicator
class StepperWithProgress extends StatefulWidget {
const StepperWithProgress({super.key});
@override
State<StepperWithProgress> createState() => _StepperWithProgressState();
}
class _StepperWithProgressState extends State<StepperWithProgress> {
int _currentStep = 0;
final int _totalSteps = 4;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
_buildProgressHeader(l10n),
Expanded(
child: Stepper(
currentStep: _currentStep,
type: StepperType.horizontal,
onStepContinue: () {
if (_currentStep < _totalSteps - 1) {
setState(() => _currentStep++);
}
},
onStepCancel: () {
if (_currentStep > 0) {
setState(() => _currentStep--);
}
},
steps: _buildSteps(l10n),
),
),
],
);
}
Widget _buildProgressHeader(AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.stepperProgress(_currentStep + 1, _totalSteps),
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${((_currentStep + 1) / _totalSteps * 100).round()}%',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
);
}
List<Step> _buildSteps(AppLocalizations l10n) {
return [
Step(
title: Text(l10n.registrationStepAccount),
content: const SizedBox(height: 100),
isActive: _currentStep >= 0,
),
Step(
title: Text(l10n.registrationStepProfile),
content: const SizedBox(height: 100),
isActive: _currentStep >= 1,
),
Step(
title: Text(l10n.registrationStepPreferences),
content: const SizedBox(height: 100),
isActive: _currentStep >= 2,
),
Step(
title: Text(l10n.registrationStepConfirmation),
content: const SizedBox(height: 100),
isActive: _currentStep >= 3,
),
];
}
}
Custom Stepper with Optional Steps
class CustomStepperWithOptional extends StatefulWidget {
const CustomStepperWithOptional({super.key});
@override
State<CustomStepperWithOptional> createState() =>
_CustomStepperWithOptionalState();
}
class _CustomStepperWithOptionalState extends State<CustomStepperWithOptional> {
int _currentStep = 0;
final Set<int> _completedSteps = {};
final Set<int> _skippedSteps = {};
// Step 2 is optional
final Set<int> _optionalSteps = {2};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stepper(
currentStep: _currentStep,
onStepContinue: _handleContinue,
onStepCancel: _handleCancel,
controlsBuilder: (context, details) {
return _buildCustomControls(context, details, l10n);
},
steps: _buildStepsWithOptional(l10n),
);
}
List<Step> _buildStepsWithOptional(AppLocalizations l10n) {
return [
_buildStep(0, l10n.registrationStepAccount, l10n),
_buildStep(1, l10n.registrationStepProfile, l10n),
_buildStep(2, l10n.registrationStepPreferences, l10n, isOptional: true),
_buildStep(3, l10n.registrationStepConfirmation, l10n),
];
}
Step _buildStep(
int index,
String title,
AppLocalizations l10n, {
bool isOptional = false,
}) {
return Step(
title: Text(title),
subtitle: isOptional ? Text(l10n.stepOptional) : null,
content: _buildStepContent(index, l10n),
isActive: _currentStep >= index,
state: _getStepState(index),
);
}
Widget _buildStepContent(int index, AppLocalizations l10n) {
return Container(
padding: const EdgeInsets.all(16),
child: Text('Content for step ${index + 1}'),
);
}
StepState _getStepState(int step) {
if (_skippedSteps.contains(step)) return StepState.disabled;
if (_completedSteps.contains(step)) return StepState.complete;
if (_currentStep == step) return StepState.editing;
return StepState.indexed;
}
Widget _buildCustomControls(
BuildContext context,
ControlsDetails details,
AppLocalizations l10n,
) {
final isOptional = _optionalSteps.contains(_currentStep);
final isLastStep = _currentStep == 3;
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: details.onStepContinue,
child: Text(isLastStep ? l10n.stepComplete : l10n.stepContinue),
),
if (isOptional)
OutlinedButton(
onPressed: _handleSkip,
child: Text(l10n.stepSkip),
),
if (_currentStep > 0)
TextButton(
onPressed: details.onStepCancel,
child: Text(l10n.stepBack),
),
],
),
);
}
void _handleContinue() {
_completedSteps.add(_currentStep);
if (_currentStep < 3) {
setState(() => _currentStep++);
}
}
void _handleCancel() {
if (_currentStep > 0) {
setState(() => _currentStep--);
}
}
void _handleSkip() {
_skippedSteps.add(_currentStep);
if (_currentStep < 3) {
setState(() => _currentStep++);
}
}
}
Checkout Stepper Example
E-commerce Checkout Flow
class CheckoutStepper extends StatefulWidget {
const CheckoutStepper({super.key});
@override
State<CheckoutStepper> createState() => _CheckoutStepperState();
}
class _CheckoutStepperState extends State<CheckoutStepper> {
int _currentStep = 0;
final _formKeys = List.generate(4, (_) => GlobalKey<FormState>());
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.checkoutTitle),
),
body: Stepper(
currentStep: _currentStep,
onStepContinue: _validateAndContinue,
onStepCancel: _goBack,
controlsBuilder: _buildControls,
steps: [
Step(
title: Text(l10n.checkoutStepShipping),
subtitle: Text(l10n.checkoutStepShippingSubtitle),
content: _buildShippingForm(l10n),
isActive: _currentStep >= 0,
state: _currentStep > 0 ? StepState.complete : StepState.editing,
),
Step(
title: Text(l10n.checkoutStepPayment),
subtitle: Text(l10n.checkoutStepPaymentSubtitle),
content: _buildPaymentForm(l10n),
isActive: _currentStep >= 1,
state: _currentStep > 1 ? StepState.complete :
_currentStep == 1 ? StepState.editing : StepState.indexed,
),
Step(
title: Text(l10n.checkoutStepReview),
subtitle: Text(l10n.checkoutStepReviewSubtitle),
content: _buildReviewSection(l10n),
isActive: _currentStep >= 2,
state: _currentStep > 2 ? StepState.complete :
_currentStep == 2 ? StepState.editing : StepState.indexed,
),
Step(
title: Text(l10n.checkoutStepConfirmation),
content: _buildConfirmationSection(l10n),
isActive: _currentStep >= 3,
state: _currentStep == 3 ? StepState.editing : StepState.indexed,
),
],
),
);
}
Widget _buildControls(BuildContext context, ControlsDetails details) {
final l10n = AppLocalizations.of(context)!;
final isLastStep = _currentStep == 3;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
ElevatedButton.icon(
onPressed: details.onStepContinue,
icon: Icon(isLastStep ? Icons.check : Icons.arrow_forward),
label: Text(isLastStep ? l10n.placeOrder : l10n.stepContinue),
),
const SizedBox(width: 12),
if (_currentStep > 0)
TextButton.icon(
onPressed: details.onStepCancel,
icon: const Icon(Icons.arrow_back),
label: Text(l10n.stepBack),
),
],
),
);
}
Widget _buildShippingForm(AppLocalizations l10n) {
return Form(
key: _formKeys[0],
child: Column(
children: [
TextFormField(
decoration: InputDecoration(
labelText: l10n.shippingFullName,
hintText: l10n.shippingFullNameHint,
),
validator: (value) {
if (value?.isEmpty ?? true) {
return l10n.fieldRequired;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.shippingAddress,
hintText: l10n.shippingAddressHint,
),
maxLines: 2,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
decoration: InputDecoration(labelText: l10n.shippingCity),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: InputDecoration(labelText: l10n.shippingPostalCode),
),
),
],
),
],
),
);
}
Widget _buildPaymentForm(AppLocalizations l10n) {
return Form(
key: _formKeys[1],
child: Column(
children: [
TextFormField(
decoration: InputDecoration(
labelText: l10n.paymentCardNumber,
hintText: l10n.paymentCardNumberHint,
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: l10n.paymentExpiry,
hintText: 'MM/YY',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: l10n.paymentCvv,
hintText: '***',
),
obscureText: true,
),
),
],
),
],
),
);
}
Widget _buildReviewSection(AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.orderSummary,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_buildOrderItem('Product 1', '\$29.99'),
_buildOrderItem('Product 2', '\$49.99'),
const Divider(),
_buildOrderItem(l10n.subtotal, '\$79.98'),
_buildOrderItem(l10n.shipping, '\$5.99'),
_buildOrderItem(l10n.tax, '\$6.40'),
const Divider(),
_buildOrderItem(l10n.total, '\$92.37', isBold: true),
],
);
}
Widget _buildOrderItem(String label, String value, {bool isBold = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: isBold ? const TextStyle(fontWeight: FontWeight.bold) : null,
),
Text(
value,
style: isBold ? const TextStyle(fontWeight: FontWeight.bold) : null,
),
],
),
);
}
Widget _buildConfirmationSection(AppLocalizations l10n) {
return Column(
children: [
const Icon(Icons.check_circle, size: 64, color: Colors.green),
const SizedBox(height: 16),
Text(
l10n.orderConfirmed,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(l10n.orderConfirmationMessage),
],
);
}
void _validateAndContinue() {
if (_currentStep < 2) {
if (_formKeys[_currentStep].currentState?.validate() ?? false) {
setState(() => _currentStep++);
}
} else if (_currentStep == 2) {
setState(() => _currentStep++);
}
}
void _goBack() {
if (_currentStep > 0) {
setState(() => _currentStep--);
}
}
}
Accessibility Considerations
Screen Reader Support
class AccessibleStepper extends StatefulWidget {
const AccessibleStepper({super.key});
@override
State<AccessibleStepper> createState() => _AccessibleStepperState();
}
class _AccessibleStepperState extends State<AccessibleStepper> {
int _currentStep = 0;
final int _totalSteps = 4;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.stepperProgress(_currentStep + 1, _totalSteps),
child: Stepper(
currentStep: _currentStep,
onStepContinue: () {
if (_currentStep < _totalSteps - 1) {
setState(() => _currentStep++);
_announceStepChange(l10n);
}
},
onStepCancel: () {
if (_currentStep > 0) {
setState(() => _currentStep--);
_announceStepChange(l10n);
}
},
steps: _buildAccessibleSteps(l10n),
),
);
}
void _announceStepChange(AppLocalizations l10n) {
SemanticsService.announce(
l10n.stepperProgress(_currentStep + 1, _totalSteps),
Directionality.of(context),
);
}
List<Step> _buildAccessibleSteps(AppLocalizations l10n) {
return [
Step(
title: Semantics(
label: '${l10n.registrationStepAccount}, ${_getStepStatus(0, l10n)}',
child: Text(l10n.registrationStepAccount),
),
content: const SizedBox(height: 100),
isActive: _currentStep >= 0,
state: _getStepState(0),
),
// ... more steps
];
}
String _getStepStatus(int step, AppLocalizations l10n) {
if (_currentStep > step) return l10n.stepStatusComplete;
if (_currentStep == step) return l10n.stepStatusCurrent;
return l10n.stepStatusPending;
}
StepState _getStepState(int step) {
if (_currentStep > step) return StepState.complete;
if (_currentStep == step) return StepState.editing;
return StepState.indexed;
}
}
Testing Stepper Localization
Widget Tests
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedStepper', () {
testWidgets('displays localized step titles', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: LocalizedStepper()),
),
);
expect(find.text('Cuenta'), findsOneWidget);
expect(find.text('Perfil'), findsOneWidget);
});
testWidgets('shows localized continue button', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: LocalizedStepper()),
),
);
expect(find.text('Continuar'), findsOneWidget);
});
testWidgets('updates progress indicator on step change', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: StepperWithProgress()),
),
);
expect(find.text('Step 1 of 4'), findsOneWidget);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('Step 2 of 4'), findsOneWidget);
});
});
}
Best Practices
- Keep step titles concise - Use short, action-oriented labels
- Provide clear subtitles - Help users understand what each step requires
- Show progress - Display "Step X of Y" for orientation
- Enable navigation - Allow users to go back to previous steps
- Validate before advancing - Prevent errors by validating each step
- Mark optional steps - Clearly indicate which steps can be skipped
- Test RTL layouts - Ensure stepper works correctly in RTL languages
- Announce changes - Use semantic announcements for accessibility
Conclusion
Proper stepper localization ensures users worldwide can navigate multi-step processes confidently. By following these patterns, your Flutter steppers will provide clear guidance in any language.