SwiftUI Build Guide

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.

Last updated: 2026-07-17 8 min read By Ahmed Gagan, iOS Engineer
Quick Answer

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.

Core API
TabView + .tabViewStyle(.page)
Persistence
@AppStorage("hasCompletedOnboarding")
Minimum iOS
iOS 16 (page style needs iOS 14+)
In The Swift Kit
Prebuilt onboarding flow with page indicators, gating, and analytics baked in

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:).
Debug reset helper
#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.

Get The Swift Kit — $99

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. 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. 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. 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. 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. 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. 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?
Store a Bool with @AppStorage("hasCompletedOnboarding"), default it to false, and read it at your root view. Show OnboardingView while it's false and set it to true from the Get Started button. Because @AppStorage writes to UserDefaults, the flag survives relaunches so onboarding never reappears.
Why are the page indicator dots in my paged TabView invisible?
The default .page index dots render in white, which disappears on light backgrounds. Fix it by adding .indexViewStyle(.page(backgroundDisplayMode: .always)) to draw a contrasting pill behind the dots, or place your TabView over a dark or tinted background so the white dots read clearly.
How do I advance to the next onboarding page from a Next button instead of swiping?
Bind the TabView selection to a @State Int and give each page a matching .tag(index). In the Next button's action, wrap selection += 1 in withAnimation so the pager slides to the next tag programmatically, exactly as if the user had swiped.
Should I reset hasCompletedOnboarding when the user logs out?
Usually no — onboarding teaches the app, not the account, so leave it set after logout. If your onboarding is account-specific (for example, a per-user setup wizard), store the flag per user ID instead of a single global key, or clear it in your sign-out handler with UserDefaults.standard.removeObject(forKey:).
How can I re-run onboarding for testing without deleting the app each time?
Add a DEBUG-only helper that calls UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding"), then trigger it from a hidden debug menu or a long-press gesture. On the next relaunch the flag is absent, defaults back to false, and the onboarding flow shows again.

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 — $99

One-time purchase · Lifetime updates · 14-day refund