Answer: Which Onboarding Pattern Should You Use?
Use the Feature Highlight Carousel if your app has 3-5 key features users need to understand. Use the Permission Primer if your app requires permissions (camera, notifications, location) that users might decline. Use Progressive Disclosure if your app requires initial configuration (choosing interests, setting goals) before delivering value. Most production apps combine elements from all three.
Why Onboarding Matters More Than You Think
According to industry data, 25% of users abandon an app after a single use. The onboarding experience is your best chance to show users why your app is worth keeping. A well-designed onboarding flow:
- Increases day-1 retention by 30-50%
- Dramatically improves permission opt-in rates (up to 3x higher than cold requests)
- Primes users for the value proposition before showing a paywall
- Reduces support tickets by teaching core features upfront
The three patterns below cover the most common onboarding scenarios. Each includes full Dart code that you can copy into your Flutter project.
Pattern 1: Feature Highlight Carousel
The most common onboarding pattern. Users swipe through 3-5 screens, each highlighting a key feature with an illustration, title, and description. A page indicator shows progress, and a CTA button advances to the next screen or finishes onboarding.
Best for: Apps with distinct features that benefit from visual explanation. Content apps, productivity tools, social apps.
Conversion tip: Keep it to 3 screens maximum. Each additional screen reduces completion rate by roughly 10%. The last screen should contain your strongest value proposition and a clear CTA.
class CarouselOnboarding extends StatefulWidget {
final VoidCallback onComplete;
const CarouselOnboarding({
super.key,
required this.onComplete,
});
@override
State<CarouselOnboarding> createState() =>
_CarouselOnboardingState();
}
class _CarouselOnboardingState
extends State<CarouselOnboarding> {
final _controller = PageController();
int _currentPage = 0;
final _pages = const [
OnboardingPage(
icon: Icons.edit_note_rounded,
title: 'Capture Ideas Instantly',
description:
'Write notes, snap photos, or record voice '
'memos — all in one place.',
),
OnboardingPage(
icon: Icons.cloud_sync_rounded,
title: 'Sync Everywhere',
description:
'Your notes follow you across every device, '
'always up to date.',
),
OnboardingPage(
icon: Icons.lock_rounded,
title: 'Private and Secure',
description:
'End-to-end encryption keeps your thoughts '
'safe. Only you can read them.',
),
];
@override
Widget build(BuildContext context) {
final isLastPage = _currentPage == _pages.length - 1;
final colors = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Skip button
Align(
alignment: Alignment.topRight,
child: TextButton(
onPressed: widget.onComplete,
child: Text(
'Skip',
style: TextStyle(
color: colors.onSurfaceVariant,
),
),
),
),
// Page content
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (index) {
setState(() => _currentPage = index);
},
itemCount: _pages.length,
itemBuilder: (context, index) {
final page = _pages[index];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
page.icon,
size: 120,
color: colors.primary,
),
const SizedBox(height: 48),
Text(
page.title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
page.description,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: colors
.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
},
),
),
// Page indicator
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: List.generate(
_pages.length,
(index) => AnimatedContainer(
duration: const Duration(
milliseconds: 300,
),
margin: const EdgeInsets.symmetric(
horizontal: 4,
),
height: 8,
width: _currentPage == index ? 24 : 8,
decoration: BoxDecoration(
color: _currentPage == index
? colors.primary
: colors.outlineVariant,
borderRadius:
BorderRadius.circular(4),
),
),
),
),
const SizedBox(height: 32),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
),
child: FilledButton(
onPressed: isLastPage
? widget.onComplete
: () => _controller.nextPage(
duration: const Duration(
milliseconds: 300,
),
curve: Curves.easeInOut,
),
style: FilledButton.styleFrom(
minimumSize:
const Size(double.infinity, 56),
),
child: Text(
isLastPage
? 'Get Started'
: 'Continue',
),
),
),
const SizedBox(height: 32),
],
),
),
);
}
}
class OnboardingPage {
final IconData icon;
final String title;
final String description;
const OnboardingPage({
required this.icon,
required this.title,
required this.description,
});
}Pattern 2: Permission Primer
This pattern pre-explains why your app needs specific permissions before triggering the system dialog. Research shows that users are 3x more likely to grant permissions when they understand the benefit first. The pattern shows a dedicated screen for each permission with a clear explanation and a "Continue" button that triggers the actual system prompt.
Best for: Apps that require camera, notifications, location, or health data access. Fitness apps, photo apps, social apps, delivery apps.
Conversion tip: Always explain the benefit to the user, not the technical requirement. "Get reminders to stay on track" is better than "We need notification permission." Show this screen before the system dialog — never cold-call a permission request.
class PermissionPrimerScreen extends StatelessWidget {
final String title;
final String description;
final IconData icon;
final VoidCallback onAllow;
final VoidCallback onSkip;
const PermissionPrimerScreen({
super.key,
required this.title,
required this.description,
required this.icon,
required this.onAllow,
required this.onSkip,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
// Skip option
Align(
alignment: Alignment.topRight,
child: TextButton(
onPressed: onSkip,
child: const Text('Not now'),
),
),
const Spacer(),
// Permission illustration
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 56,
color: colors.onPrimaryContainer,
),
),
const SizedBox(height: 40),
// Title
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const Spacer(),
// Allow button
FilledButton(
onPressed: onAllow,
style: FilledButton.styleFrom(
minimumSize:
const Size(double.infinity, 56),
),
child: const Text('Enable'),
),
const SizedBox(height: 12),
// Skip button
TextButton(
onPressed: onSkip,
child: Text(
'Maybe later',
style: TextStyle(
color: colors.onSurfaceVariant,
),
),
),
],
),
),
),
);
}
}
// Usage in onboarding flow
PermissionPrimerScreen(
icon: Icons.notifications_active_rounded,
title: 'Never Miss a Reminder',
description:
'Get gentle nudges to write daily, stay '
'consistent, and build your journaling habit.',
onAllow: () async {
// Request the actual system permission
final settings = await FirebaseMessaging.instance
.requestPermission();
if (settings.authorizationStatus ==
AuthorizationStatus.authorized) {
// Permission granted — continue
navigateToNextStep();
}
},
onSkip: () => navigateToNextStep(),
)Pattern 3: Progressive Disclosure
This pattern asks users to make choices that customize their experience — selecting interests, setting goals, or configuring preferences. Each screen collects one piece of information. The app uses these choices to personalize content from the first session.
Best for: Content apps (news, learning, fitness), apps that recommend content, apps where personalization drives retention.
Conversion tip: Limit to 2-3 questions. Each additional question loses roughly 15% of users. Show a progress indicator so users know how many steps remain. Use the collected data immediately — show personalized content on the very first screen after onboarding.
class ProgressiveOnboarding extends StatefulWidget {
final Function(UserPreferences) onComplete;
const ProgressiveOnboarding({
super.key,
required this.onComplete,
});
@override
State<ProgressiveOnboarding> createState() =>
_ProgressiveOnboardingState();
}
class _ProgressiveOnboardingState
extends State<ProgressiveOnboarding> {
int _step = 0;
String? _selectedGoal;
final Set<String> _selectedTopics = {};
static const _totalSteps = 3;
final _goals = [
('Daily journaling', Icons.edit_calendar_rounded),
('Project planning', Icons.task_alt_rounded),
('Creative writing', Icons.auto_stories_rounded),
('Study notes', Icons.school_rounded),
];
final _topics = [
'Personal',
'Work',
'Health',
'Finance',
'Travel',
'Creativity',
'Learning',
'Relationships',
];
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Progress bar
Row(
children: List.generate(
_totalSteps,
(index) => Expanded(
child: Container(
height: 4,
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
decoration: BoxDecoration(
color: index <= _step
? colors.primary
: colors.outlineVariant,
borderRadius:
BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(height: 32),
// Step content
Expanded(
child: _buildStep(),
),
// Continue button
FilledButton(
onPressed: _canContinue
? _nextStep
: null,
style: FilledButton.styleFrom(
minimumSize:
const Size(double.infinity, 56),
),
child: Text(
_step == _totalSteps - 1
? 'Start Writing'
: 'Continue',
),
),
],
),
),
),
);
}
bool get _canContinue {
switch (_step) {
case 0:
return _selectedGoal != null;
case 1:
return _selectedTopics.isNotEmpty;
case 2:
return true; // Welcome screen
default:
return false;
}
}
void _nextStep() {
if (_step < _totalSteps - 1) {
setState(() => _step++);
} else {
widget.onComplete(UserPreferences(
goal: _selectedGoal!,
topics: _selectedTopics.toList(),
));
}
}
Widget _buildStep() {
switch (_step) {
case 0:
return _buildGoalSelection();
case 1:
return _buildTopicSelection();
case 2:
return _buildWelcome();
default:
return const SizedBox.shrink();
}
}
Widget _buildGoalSelection() {
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'What brings you here?',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'We will customize your experience.',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 24),
...List.generate(_goals.length, (index) {
final (label, icon) = _goals[index];
final isSelected = _selectedGoal == label;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () {
setState(() => _selectedGoal = label);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? colors.primary
: colors.outlineVariant,
width: isSelected ? 2 : 1,
),
borderRadius:
BorderRadius.circular(12),
color: isSelected
? colors.primaryContainer
.withOpacity(0.3)
: null,
),
child: Row(
children: [
Icon(icon,
color: isSelected
? colors.primary
: colors.onSurfaceVariant),
const SizedBox(width: 16),
Text(label,
style: Theme.of(context)
.textTheme
.bodyLarge),
const Spacer(),
if (isSelected)
Icon(Icons.check_circle,
color: colors.primary),
],
),
),
),
);
}),
],
);
}
Widget _buildTopicSelection() {
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pick your topics',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Choose at least one to get started.',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
children: _topics.map((topic) {
final isSelected =
_selectedTopics.contains(topic);
return FilterChip(
label: Text(topic),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTopics.add(topic);
} else {
_selectedTopics.remove(topic);
}
});
},
);
}).toList(),
),
],
);
}
Widget _buildWelcome() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.celebration_rounded,
size: 80,
color: Theme.of(context)
.colorScheme
.primary,
),
const SizedBox(height: 24),
Text(
'You are all set!',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text(
'Your personalized experience is ready.',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class UserPreferences {
final String goal;
final List<String> topics;
UserPreferences({
required this.goal,
required this.topics,
});
}Persisting Onboarding Completion
You need to track whether the user has completed onboarding so you do not show it again. The simplest approach uses SharedPreferences with a GoRouter redirect:
import 'package:shared_preferences/shared_preferences.dart';
import 'package:go_router/go_router.dart';
class OnboardingService {
static const _key = 'onboarding_completed';
final SharedPreferences _prefs;
OnboardingService(this._prefs);
bool get isComplete => _prefs.getBool(_key) ?? false;
Future<void> markComplete() async {
await _prefs.setBool(_key, true);
}
}
// GoRouter configuration with onboarding redirect
final router = GoRouter(
redirect: (context, state) {
final onboarding = getIt<OnboardingService>();
final isOnboardingComplete = onboarding.isComplete;
final isOnboardingRoute =
state.matchedLocation == '/onboarding';
if (!isOnboardingComplete && !isOnboardingRoute) {
return '/onboarding';
}
if (isOnboardingComplete && isOnboardingRoute) {
return '/home';
}
return null;
},
routes: [
GoRoute(
path: '/onboarding',
builder: (context, state) =>
CarouselOnboarding(
onComplete: () async {
await getIt<OnboardingService>()
.markComplete();
context.go('/home');
},
),
),
GoRoute(
path: '/home',
builder: (context, state) =>
const HomeScreen(),
),
],
);Onboarding-to-Paywall Transition
A common question: should you show a paywall immediately after onboarding? The answer depends on your app type:
- Content/utility apps: Yes — show a soft paywall after onboarding while intent is high. Include a "Start Free Trial" CTA and a clear skip option. Conversion rates from post-onboarding paywalls average 3-5%.
- Social/community apps: No — let users experience value first. Show the paywall after they have used 2-3 core features or hit a usage limit.
- Productivity apps: Maybe — show a paywall after the last onboarding step, but frame it as "unlock all features" rather than a hard gate. Let users try the free tier first.
For a detailed guide on paywall design and placement, see our Flutter paywall best practices post.
Conversion Tips That Actually Work
- Keep it to 3 screens or fewer. Data from thousands of apps shows completion rates drop 10-15% per additional screen. Three screens is the sweet spot for most apps.
- Show value, not features. "Never lose an idea again" is more compelling than "Cloud sync with 256-bit encryption." Focus on outcomes, not technology.
- Use animations sparingly. A subtle fade or slide transition between screens is good. Elaborate animations slow users down and feel gimmicky. Respect the
accessibilityReduceMotionsetting. - Always provide a skip option. Returning users who reinstall your app should not be forced through onboarding again. A "Skip" button respects their time and prevents frustration.
- Test with real users. Watch 5 people go through your onboarding. You will immediately see where they hesitate, tap the wrong thing, or lose interest. No amount of design theory replaces this.
Get All 3 Templates Pre-Built
The Flutter Kit includes all three onboarding patterns — Feature Highlight Carousel, Permission Primer, and Progressive Disclosure — production-ready and integrated with the full app flow. You configure which template to use in app_config.dart, customize the content, and you are done. Each template automatically respects your Material 3 design tokens, supports dark mode, and handles the onboarding-to-home transition with GoRouter.