How to build an onboarding flow in SwiftUI
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
-
TabView + .tabViewStyle(.page) — The single modifier transforms a standard
TabViewinto a full-screen horizontal pager. Each child view becomes one swipeable page; SwiftUI manages the scroll physics and gesture recognizers automatically. -
indexViewStyle(.page(backgroundDisplayMode: .always)) — This renders the familiar dot indicator row below the pager content. The
.alwaysmode keeps dots visible even when only one page is present, which is useful during development and edge-case states. -
@State private var currentPage — The
selection:binding onTabViewkeepscurrentPagein 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. -
@AppStorage("hasSeenOnboarding") — A single Boolean key in
UserDefaults. When the user taps "Get Started" on the last page, the property wrapper writestrue, andContentView's body re-evaluates, replacingOnboardingViewwith your main interface instantly. -
.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
-
iOS 16 safe area bleed: On iOS 16,
.indexViewStylesometimes overlaps safe-area content at the bottom. Add.safeAreaInset(edge: .bottom)around your button instead of a fixed.padding(.bottom)to handle all device sizes correctly — this matters even more on iOS 17+ with Dynamic Island devices. -
TabView page style clips child backgrounds:
TabViewwith.pagestyle renders a scroll view internally that ignores the child view's.backgroundmodifier in some configurations. SetColor(.systemBackground).ignoresSafeArea()directly on theTabView, not on individual page views, to avoid background flicker between swipes. -
VoiceOver swipe conflicts: VoiceOver's default horizontal swipe gesture for element navigation conflicts with
TabViewpage swiping. Wrap yourTabViewin an.accessibilityScrollActionmodifier and expose explicit "next page" / "previous page" actions so VoiceOver users aren't stranded on the first page.
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.