Skip to content
Onboarding Pager preview
An animated render of the SwiftUI source on this page.

Onboarding Pager

From syxUI — written for both platforms, not translated between them.

A three-page first-run intro with artwork, stretching dots, and one action.

Onboarding · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • onboarding
  • intro
  • pager
  • walkthrough
  • first run
  • pages

The actual source

OnboardingPager.swift
// Onboarding Pager · syxUI · https://syxui.dev/components/onboarding-pager
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A three-page first-run intro: artwork, headline, body, dots, one action.
///
/// Paging is a horizontal `ScrollView` with `.scrollTargetBehavior(.viewAligned)`
/// rather than a `TabView`. That keeps the swipe, the snap, and the end
/// rubber-band as system behaviour, but unlike the page-style TabView it is
/// not iOS-only and it lets the button drive position through
/// `.scrollPosition(id:)`. The dots are custom because the system ones cannot
/// be restyled.
struct OnboardingPager: View {
    struct Page: Identifiable {
        let id = UUID()
        var symbol: String
        var title: String
        var body: String
        var tint: Color
    }

    var pages: [Page] = [
        .init(symbol: "square.on.square",
              title: "Real source, both platforms",
              body: "Every component ships SwiftUI and Flutter. Copy the one you need.",
              tint: Color(red: 0.36, green: 0.42, blue: 0.98)),
        .init(symbol: "bolt.fill",
              title: "No package to install",
              body: "One file, no imports of ours. Paste it in and it is your code.",
              tint: Color(red: 0.95, green: 0.45, blue: 0.30)),
        .init(symbol: "checkmark.seal.fill",
              title: "What you see is what compiles",
              body: "Previews are rendered from the exact source on the page.",
              tint: Color(red: 0.15, green: 0.70, blue: 0.46)),
    ]

    var onFinish: () -> Void = {}
    /// Drive the page position externally, 0...1 across all pages. In this
    /// controlled mode the pager shows one page at a time and does not scroll,
    /// which is what makes it deterministic in tests and screenshots.
    var progress: Double? = nil

    @State private var scrolled: Int?

    private var current: Int {
        if let progress {
            return min(Int(progress * Double(pages.count)), pages.count - 1)
        }
        return scrolled ?? 0
    }

    var body: some View {
        VStack(spacing: 0) {
            if progress == nil {
                pager
            } else {
                pageView(pages[current])
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }

            dots
                .padding(.top, 4)

            Button(action: advance) {
                Text(current == pages.count - 1 ? "Get started" : "Continue")
                    .font(.system(size: 17, weight: .semibold))
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity)
                    .frame(height: 54)
                    .background(.black, in: Capsule())
            }
            .buttonStyle(.plain)
            .padding(.horizontal, 26)
            .padding(.top, 22)
            .padding(.bottom, 10)
        }
        .frame(width: 320, height: 460)
        .animation(.snappy(duration: 0.3), value: current)
    }

    private var pager: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 0) {
                ForEach(Array(pages.enumerated()), id: \.element.id) { offset, page in
                    pageView(page)
                        .containerRelativeFrame(.horizontal)
                        .id(offset)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .scrollPosition(id: $scrolled, anchor: .center)
        .scrollIndicators(.hidden)
    }

    private func pageView(_ page: Page) -> some View {
        VStack(spacing: 0) {
            ZStack {
                Circle()
                    .fill(page.tint.opacity(0.12))
                    .frame(width: 132, height: 132)
                Image(systemName: page.symbol)
                    .font(.system(size: 46, weight: .medium))
                    .foregroundStyle(page.tint)
            }
            .padding(.bottom, 30)

            Text(page.title)
                .font(.system(size: 23, weight: .bold))
                .multilineTextAlignment(.center)

            Text(page.body)
                .font(.system(size: 15))
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
                .padding(.top, 8)
        }
        .padding(.horizontal, 30)
        .accessibilityElement(children: .combine)
    }

    private var dots: some View {
        HStack(spacing: 6) {
            ForEach(0..<pages.count, id: \.self) { offset in
                Capsule()
                    .fill(offset == current ? Color.black : Color(white: 0.85))
                    // The active dot stretches rather than just darkening, so
                    // position is legible without relying on colour.
                    .frame(width: offset == current ? 20 : 7, height: 7)
            }
        }
        .accessibilityElement()
        .accessibilityLabel("Page \(current + 1) of \(pages.count)")
    }

    private func advance() {
        if current == pages.count - 1 {
            onFinish()
        } else {
            withAnimation(.snappy(duration: 0.32)) { scrolled = current + 1 }
        }
    }
}

#Preview {
    OnboardingPager()
        .padding(20)
}
iOS 17 · No dependencies

SwiftUI note. TabView with .page style keeps the swipe, the end rubber-band, and VoiceOver page navigation as system behaviour. Only the dots are custom.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27