How to Add a Multi-Page Onboarding Flow to a SwiftUI App
A step-by-step guide to building a paged onboarding experience in SwiftUI — swipeable slides, animated dots, a final Get Started button, and first-launch-only gating with @AppStorage.
You build a SwiftUI onboarding flow by wrapping a set of slide views in a TabView with the .tabViewStyle(.page) modifier so users can swipe between pages. You persist completion with @AppStorage("hasCompletedOnboarding") so the flow only appears on first launch. A final "Get Started" button sets that flag to true, which flips the root view to your main app. Animated page indicators come free with .page style, or you can build a custom dot row driven by a @State selection binding.
Testing the first-launch experience repeatedly
Because @AppStorage persists to UserDefaults, once you complete onboarding the simulator will never show it again — which makes iterating on the design painful. Add a debug reset, or delete the app from the simulator between runs. A dev-only button that clears the flag is the fastest loop.
- Delete the app from the simulator to wipe UserDefaults entirely.
- Or add a hidden debug gesture that sets hasCompletedOnboarding = false.
- Reset a specific key in code with UserDefaults.standard.removeObject(forKey:).
#if DEBUG
extension UserDefaults {
func resetOnboarding() {
removeObject(forKey: "hasCompletedOnboarding")
}
}
#endif
// Call from a debug menu:
// UserDefaults.standard.resetOnboarding()Gotchas with the .page TabView style
The paged style is convenient but has quirks worth knowing before you ship. The index dots are white by default and can vanish on a light background, and each page must carry a unique .tag matching the selection type or programmatic paging silently fails.
- Give every page a .tag whose type matches your selection State (Int here).
- Set .indexViewStyle(.page(backgroundDisplayMode: .interactive)) so dots stay visible on light backgrounds.
- Wrap selection changes in withAnimation for a smooth slide; the raw binding jumps instantly.
- Avoid heavy work in slide bodies — TabView keeps adjacent pages alive.
Ship your SwiftUI app in 5 emails
A free 5-part course on the parts that actually stall launches: paywall, auth, onboarding, App Store review, and pricing. No fluff, unsubscribe anytime.
Skip the boilerplate onboarding wiring
The Swift Kit ships a polished, animated onboarding flow with first-launch gating and analytics already connected — drop in your copy and images and you're done.
Rather have it done for you? I set up and ship your app from $499. See how
Build the onboarding flow step by step
We'll model each slide as data, render them in a paged TabView, gate the flow on first launch, and hand off to the main app when the user taps Get Started. Every step compiles under Swift 6 and iOS 16+.
- 1
Model each onboarding page as data
Rather than hard-coding views, describe each slide with a small Identifiable struct. This keeps the TabView loop clean and makes it trivial to add or reorder pages later.
struct OnboardingPage: Identifiable { let id = UUID() let systemImage: String let title: String let subtitle: String } extension OnboardingPage { static let all: [OnboardingPage] = [ .init(systemImage: "sparkles", title: "Welcome to Aurora", subtitle: "Track your habits with a tap and stay consistent."), .init(systemImage: "chart.line.uptrend.xyaxis", title: "See Your Progress", subtitle: "Beautiful charts show your streaks at a glance."), .init(systemImage: "bell.badge", title: "Never Miss a Day", subtitle: "Gentle reminders keep you on track.") ] } - 2
Render a single slide view
Build one reusable slide that renders any OnboardingPage. Keeping it small means the paged container stays readable.
struct OnboardingSlide: View { let page: OnboardingPage var body: some View { VStack(spacing: 24) { Image(systemName: page.systemImage) .font(.system(size: 96)) .foregroundStyle(.tint) .symbolRenderingMode(.hierarchical) Text(page.title) .font(.largeTitle.bold()) .multilineTextAlignment(.center) Text(page.subtitle) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) } .padding() } } - 3
Wrap the slides in a paged TabView
TabView with .tabViewStyle(.page) turns the array into a swipeable pager. Bind selection to @State so you know the current index, and use .indexViewStyle to always show the dots. The final page swaps its content for a Get Started button.
struct OnboardingView: View { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @State private var selection = 0 private let pages = OnboardingPage.all var body: some View { TabView(selection: $selection) { ForEach(Array(pages.enumerated()), id: \.element.id) { index, page in VStack { OnboardingSlide(page: page) if index == pages.count - 1 { getStartedButton } } .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .always)) .indexViewStyle(.page(backgroundDisplayMode: .interactive)) .animation(.easeInOut, value: selection) } private var getStartedButton: some View { Button("Get Started") { withAnimation { hasCompletedOnboarding = true } } .buttonStyle(.borderedProminent) .controlSize(.large) .padding(.top, 32) } } - 4
Gate the flow at your app root
Read the same @AppStorage flag at the root. When it's false, show onboarding; when the Get Started button flips it to true, SwiftUI re-renders and drops the user into the main app automatically.
struct RootView: View { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false var body: some View { if hasCompletedOnboarding { ContentView() } else { OnboardingView() .transition(.opacity) } } } - 5
Add a Skip affordance and a Next button
Swiping is fine, but many users tap. Advance the selection index programmatically and offer Skip on non-final pages so the flow never traps anyone.
private var navigationControls: some View { HStack { Button("Skip") { hasCompletedOnboarding = true } .opacity(selection == pages.count - 1 ? 0 : 1) Spacer() if selection < pages.count - 1 { Button("Next") { withAnimation { selection += 1 } } .buttonStyle(.bordered) } } .padding(.horizontal, 24) } - 6
Wire it into the app entry point
Set RootView as the WindowGroup content. Nothing else is required — the @AppStorage flag lives in UserDefaults and survives relaunches.
@main struct AuroraApp: App { var body: some Scene { WindowGroup { RootView() } } }
Frequently Asked Questions
How do I make my SwiftUI onboarding flow show only on the very first launch?
Why are the page indicator dots in my paged TabView invisible?
How do I advance to the next onboarding page from a Next button instead of swiping?
Should I reset hasCompletedOnboarding when the user logs out?
How can I re-run onboarding for testing without deleting the app each time?
Keep exploring
Ship your onboarding in an afternoon, not a week
The Swift Kit is a $99 one-time SwiftUI boilerplate with onboarding, Supabase auth, RevenueCat paywalls, and a full design system. Lifetime updates, 14-day refund.
Get The Swift Kit — $99One-time purchase · Lifetime updates · 14-day refund