```html SwiftUI: How to Build Onboarding Flow (iOS 17+, 2026)

How to build an onboarding flow in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: TabView / PageTabViewStyle Updated: May 11, 2026
TL;DR

Wrap your onboarding pages inside a TabView styled with .tabViewStyle(.page) to get a swipeable pager with free dot indicators. Persist completion with @AppStorage so the flow only shows once.

struct OnboardingView: View {
    @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false
    @State private var currentPage = 0
    let pages = OnboardingPage.all

    var body: some View {
        TabView(selection: $currentPage) {
            ForEach(pages.indices, id: \.self) { index in
                OnboardingPageView(page: pages[index])
                    .tag(index)
            }
        }
        .tabViewStyle(.page)
        .indexViewStyle(.page(backgroundDisplayMode: .always))
    }
}

Full implementation

The pattern below separates data (OnboardingPage struct), presentation (OnboardingPageView), and orchestration (OnboardingView) so each piece stays testable and composable. @AppStorage writes a Bool to UserDefaults automatically, preventing the flow from reappearing after the user dismisses it. A dedicated Continue / Get Started button tracks the current tab index so the final page shows a distinct call-to-action.

import SwiftUI

// MARK: - Data model

struct OnboardingPage: Identifiable {
    let id = UUID()
    let symbol: String
    let title: String
    let body: String

    static let all: [OnboardingPage] = [
        OnboardingPage(
            symbol: "wand.and.stars",
            title: "Welcome to MyApp",
            body: "The fastest way to get things done—\nright from your pocket."
        ),
        OnboardingPage(
            symbol: "bolt.fill",
            title: "Blazing Fast",
            body: "Everything syncs instantly across\nall your Apple devices."
        ),
        OnboardingPage(
            symbol: "lock.shield.fill",
            title: "Private by Design",
            body: "Your data stays on your device.\nWe never see it."
        ),
    ]
}

// MARK: - Single page

struct OnboardingPageView: View {
    let page: OnboardingPage

    var body: some View {
        VStack(spacing: 24) {
            Spacer()
            Image(systemName: page.symbol)
                .font(.system(size: 72, weight: .regular))
                .foregroundStyle(.tint)
                .accessibilityHidden(true)

            Text(page.title)
                .font(.largeTitle.bold())
                .multilineTextAlignment(.center)

            Text(page.body)
                .font(.body)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 32)
            Spacer()
            Spacer()
        }
        .accessibilityElement(children: .combine)
    }
}

// MARK: - Onboarding container

struct OnboardingView: View {
    @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false
    @State private var currentPage = 0

    private let pages = OnboardingPage.all
    private var isLastPage: Bool { currentPage == pages.count - 1 }

    var body: some View {
        VStack(spacing: 0) {
            TabView(selection: $currentPage) {
                ForEach(pages.indices, id: \.self) { index in
                    OnboardingPageView(page: pages[index])
                        .tag(index)
                }
            }
            .tabViewStyle(.page)
            .indexViewStyle(.page(backgroundDisplayMode: .always))
            .animation(.easeInOut, value: currentPage)

            // CTA button
            Button {
                if isLastPage {
                    hasSeenOnboarding = true
                } else {
                    withAnimation { currentPage += 1 }
                }
            } label: {
                Text(isLastPage ? "Get Started" : "Continue")
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.accentColor)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: 16))
            }
            .padding(.horizontal, 24)
            .padding(.bottom, 40)
            .accessibilityLabel(isLastPage ? "Get Started" : "Continue to next page")
        }
        .background(Color(.systemBackground))
    }
}

// MARK: - Root entry point (toggle gate)

struct ContentView: View {
    @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false

    var body: some View {
        if hasSeenOnboarding {
            Text("Main app content goes here")
        } else {
            OnboardingView()
        }
    }
}

// MARK: - Preview

#Preview("Onboarding") {
    ContentView()
}

How it works

  1. TabView + .tabViewStyle(.page) — The single modifier transforms a standard TabView into a full-screen horizontal pager. Each child view becomes one swipeable page; SwiftUI manages the scroll physics and gesture recognizers automatically.
  2. indexViewStyle(.page(backgroundDisplayMode: .always)) — This renders the familiar dot indicator row below the pager content. The .always mode keeps dots visible even when only one page is present, which is useful during development and edge-case states.
  3. @State private var currentPage — The selection: binding on TabView keeps currentPage in sync as the user swipes. The CTA button reads this value to decide between "Continue" and "Get Started" labels, and writes to it to advance pages programmatically.
  4. @AppStorage("hasSeenOnboarding") — A single Boolean key in UserDefaults. When the user taps "Get Started" on the last page, the property wrapper writes true, and ContentView's body re-evaluates, replacing OnboardingView with your main interface instantly.
  5. .accessibilityElement(children: .combine) on OnboardingPageView — This merges the symbol, title, and body into a single VoiceOver announcement per page, so screen-reader users hear a coherent sentence rather than three separate reads.

Variants

Skip button + progress dots

Add a skip affordance in the top-right corner so power users can bypass all pages. Track progress with a custom dot row if you need more design control than the system indicator provides.

struct OnboardingView: View {
    @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding = false
    @State private var currentPage = 0
    private let pages = OnboardingPage.all

    var body: some View {
        ZStack(alignment: .topTrailing) {
            TabView(selection: $currentPage) {
                ForEach(pages.indices, id: \.self) { i in
                    OnboardingPageView(page: pages[i]).tag(i)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))

            // Custom dot row
            HStack(spacing: 8) {
                ForEach(pages.indices, id: \.self) { i in
                    Capsule()
                        .fill(i == currentPage ? Color.accentColor : Color.secondary.opacity(0.3))
                        .frame(width: i == currentPage ? 20 : 8, height: 8)
                        .animation(.spring(response: 0.3), value: currentPage)
                }
            }
            .padding(.top, 16)
            .frame(maxWidth: .infinity)

            // Skip button
            Button("Skip") { hasSeenOnboarding = true }
                .font(.subheadline.weight(.medium))
                .foregroundStyle(.secondary)
                .padding()
        }
    }
}

Resetting onboarding in Settings

Provide a reset button in your app's Settings screen so QA and users can replay the flow. Because @AppStorage maps directly to UserDefaults, a single write resets the gate:

// Inside a SettingsView
Button("Reset Onboarding", role: .destructive) {
    UserDefaults.standard.removeObject(forKey: "hasSeenOnboarding")
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement an onboarding flow in SwiftUI for iOS 17+.
Use TabView with PageTabViewStyle and AppStorage.
Include a Continue / Get Started button that advances pages
and dismisses the flow on the last page.
Make it accessible (VoiceOver labels, accessibilityScrollAction).
Add a #Preview with realistic sample data (3 pages).

Drop this prompt into Soarias's Build phase after your screen mockups are approved — it slots directly into the feature scaffolding step and generates the full onboarding module ready for your main ContentView gate.

Related

FAQ

Does this work on iOS 16?

Yes — TabView with PageTabViewStyle has been available since iOS 14. However, iOS 17 introduced smoother spring-based scroll physics and fixed several safe-area edge cases for .page style pagers. If you must support iOS 16, remove any #Preview macro usage (use PreviewProvider instead) and test the bottom safe-area inset manually on older simulators.

How do I prevent the user from swiping backwards past the first page?

The built-in pager doesn't expose a bounce-disable API, but you can add a custom .simultaneousGesture(DragGesture()) on the first page to intercept left-to-right drags and call .gesture(_:including:) with .subviews to absorb the swipe. A cleaner alternative is to set currentPage back to 0 in an .onChange(of: currentPage) modifier when currentPage < 0 — though TabView already clamps the index, a transition back to page 0 with no visible content prevents user confusion.

What's the UIKit equivalent?

In UIKit this pattern maps to a UIPageViewController with transitionStyle: .scroll. You supply page view controllers through a data source delegate, and a UIPageControl provides the dot indicators. The SwiftUI approach is significantly less code — TabView(.page) replaces UIPageViewController, its data source delegate, the UIPageControl wiring, and the container UIViewController entirely.

Last reviewed: 2026-05-11 by the Soarias team.

```