Onboarding Pager
A full-screen onboarding flow with swipeable pages, a progress indicator and a button that advances or finishes.

- Scrolling
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- onboarding · welcome · pager · walkthrough · intro · first launch · tutorial · onboarding · bienvenida · paginador · recorrido · introducción · primer inicio · tutorial
Description
Section titled “Description”A full-screen onboarding flow with swipeable pages, a progress indicator and a button that advances or finishes. onFinish runs once.
struct OnboardingPagerDemo: View { @State private var run = 0
private let pages = [ OnboardingPagerPage(symbol: "sparkles", title: "Welcome", message: "A calmer way to keep track of your day.", colors: [.indigo, .purple]), OnboardingPagerPage(symbol: "bell.badge.fill", title: "Stay on Time", message: "Gentle reminders, only when they matter.", colors: [.orange, .pink]), OnboardingPagerPage(symbol: "checkmark.seal.fill", title: "All Set", message: "Your first list is ready and waiting.", colors: [.teal, .blue]), ]
var body: some View { OnboardingPager(pages: pages, onFinish: { run += 1 }) .id(run) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
pages | [OnboardingPagerPage] | Required |
finishTitle | LocalizedStringResource | "Get Started" |
onFinish | () -> Void | {} |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct OnboardingPagerPage { var symbol: String var title: LocalizedStringResource var message: LocalizedStringResource var colors: [Color]}
/// A full-screen onboarding flow with swipeable pages, a progress indicator and a button/// that advances or finishes. `onFinish` runs once.struct OnboardingPager: View { let pages: [OnboardingPagerPage] var finishTitle: LocalizedStringResource = "Get Started" var onFinish: () -> Void = {}
@State private var page = 0 @State private var didFinish = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var isLastPage: Bool { page >= pages.count - 1 }
var body: some View { if pages.isEmpty { Color.clear } else { VStack(spacing: 20) { TabView(selection: $page) { ForEach(Array(pages.enumerated()), id: \.offset) { index, item in OnboardingPagerContent(page: item, isActive: index == page) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never))
indicator
Button(action: advance) { Text(isLastPage ? finishTitle : "Continue") .font(.headline) .frame(maxWidth: .infinity, minHeight: 50) } .buttonStyle(.plain) .foregroundStyle(.black) .background(.white, in: .capsule) .padding(.horizontal, 24) .padding(.bottom, 12) } .foregroundStyle(.white) .background { LinearGradient(colors: currentColors, startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea() } .animation(reduceMotion ? nil : .easeInOut(duration: 0.35), value: page) .sensoryFeedback(.selection, trigger: page) .onChange(of: pages.count) { _, count in page = min(page, max(count - 1, 0)) } } }
private var currentColors: [Color] { pages.indices.contains(page) ? pages[page].colors : [.black] }
private var indicator: some View { ViewThatFits(in: .horizontal) { HStack(spacing: 8) { ForEach(pages.indices, id: \.self) { index in Capsule() .fill(.white.opacity(index == page ? 1 : 0.4)) .frame(width: index == page ? 28 : 8, height: 8) } } Text("\(page + 1) / \(pages.count)") .font(.footnote.monospacedDigit()) } .accessibilityElement(children: .ignore) .accessibilityLabel("Page") .accessibilityValue("\(min(page + 1, pages.count)) of \(pages.count)") }
private func advance() { if isLastPage { guard !didFinish else { return } didFinish = true onFinish() } else { withAnimation(reduceMotion ? nil : .smooth(duration: 0.4)) { page += 1 } } }}
private struct OnboardingPagerContent: View { let page: OnboardingPagerPage let isActive: Bool
@ScaledMetric(relativeTo: .largeTitle) private var symbolSize: CGFloat = 96 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ViewThatFits(in: .vertical) { content ScrollView { content } } }
private var content: some View { VStack(spacing: 24) { Image(systemName: page.symbol) .font(.system(size: symbolSize, weight: .semibold)) .symbolEffect(.bounce, value: reduceMotion ? false : isActive) .accessibilityHidden(true)
VStack(spacing: 10) { Text(page.title) .font(.system(.largeTitle, design: .rounded, weight: .bold)) .accessibilityAddTraits(.isHeader) Text(page.message) .font(.body) .opacity(0.85) } .multilineTextAlignment(.center) } .padding(.horizontal, 32) .padding(.vertical, 16) .frame(maxWidth: .infinity) }}
// MARK: - Demo
struct OnboardingPagerDemo: View { @State private var run = 0
private let pages = [ OnboardingPagerPage(symbol: "sparkles", title: "Welcome", message: "A calmer way to keep track of your day.", colors: [.indigo, .purple]), OnboardingPagerPage(symbol: "bell.badge.fill", title: "Stay on Time", message: "Gentle reminders, only when they matter.", colors: [.orange, .pink]), OnboardingPagerPage(symbol: "checkmark.seal.fill", title: "All Set", message: "Your first list is ready and waiting.", colors: [.teal, .blue]), ]
var body: some View { OnboardingPager(pages: pages, onFinish: { run += 1 }) .id(run) }}
#Preview { OnboardingPagerDemo()}