What You Will Build

A multi-step onboarding screen where each page features an SF Symbol icon that scales in, a title that pushes in from the side, and a tinted background that crossfades. Capsule-shaped page indicators expand for the active page. A "Continue" button advances through the slides, and the last page shows "Start Over" to loop back.

Why This Pattern Matters

First impressions matter. A well-animated onboarding flow communicates quality before users even interact with your app's core features. This pattern uses SwiftUI's built-in transitions (scale, push, opacity) rather than custom animations, making it easy to maintain and modify.

Key SwiftUI Concepts

  • .transition(.scale.combined(with: .opacity)) for icon entrance.
  • .transition(.push(from: .trailing)) for sliding text.
  • .id() modifier to force SwiftUI to treat each page as a new view, triggering transitions.

Step 1: Define the Slide Data

@State private var currentIndex = 0

let slides: [(String, String, Color)] = [
    ("bolt.fill", "Fast & Powerful", .orange),
    ("lock.shield.fill", "Secure by Default", .blue),
    ("sparkles", "Beautiful Design", .purple),
]

Step 2: Build the Animated Content Area

The icon and text each get a unique .id() based on currentIndex so SwiftUI treats them as new views and plays their transitions:

VStack(spacing: 40) {
    Spacer()

    Image(systemName: slides[currentIndex].0)
        .font(.system(size: 100))
        .foregroundStyle(slides[currentIndex].2.gradient)
        .transition(.scale.combined(with: .opacity))
        .id(currentIndex)

    Text(slides[currentIndex].1)
        .font(.title.bold())
        .transition(.push(from: .trailing))
        .id("text_\(currentIndex)")

    Spacer()
}

Step 3: Build the Page Indicators

The active indicator is a wider capsule (30pt) while inactive ones are small circles (10pt):

HStack(spacing: 8) {
    ForEach(slides.indices, id: \.self) { i in
        Capsule()
            .fill(i == currentIndex ? slides[currentIndex].2 : .gray.opacity(0.3))
            .frame(width: i == currentIndex ? 30 : 10, height: 10)
            .animation(.spring, value: currentIndex)
    }
}

Step 4: The Navigation Button

Button {
    withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
        if currentIndex < slides.count - 1 {
            currentIndex += 1
        } else {
            currentIndex = 0
        }
    }
} label: {
    Text(currentIndex < slides.count - 1 ? "Continue" : "Start Over")
        .fontWeight(.bold)
        .foregroundStyle(.white)
        .frame(maxWidth: .infinity)
        .padding(.vertical, 16)
        .background(slides[currentIndex].2, in: Capsule())
}
.padding(.horizontal, 40)
.padding(.bottom, 40)

Tips and Pitfalls

  • The .id() modifier is critical. Without it, SwiftUI sees the same Image and Text views updating their properties, and no transition plays. Adding .id(currentIndex) tells SwiftUI to remove the old view and insert a new one, triggering the transition.
  • Tinted background creates atmosphere: Using slides[currentIndex].2.opacity(0.15) as a full-screen background color ties each page to its icon color.
  • Capsule indicators are more modern than round dots. The width animation on the active indicator draws the eye to the current position.
  • Add auto-advance: Use a Timer to automatically progress slides every 4 seconds for a hands-free preview mode.
  • Button color changes per page since it uses the current slide's color, creating visual continuity.

iOS Version: iOS 16+ (for .push transition; use .slide for iOS 15)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.