```html SwiftUI: How to Parallax Scroll (iOS 17+, 2026)

How to Build Parallax Scroll in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: GeometryReader / ScrollView Updated: May 12, 2026
TL;DR

Apply .coordinateSpace(name: "scroll") to your ScrollView, then inside each card wrap the background in a GeometryReader and offset it by minY * 0.4 — the background moves at 40 % scroll speed, creating a natural depth illusion.

ScrollView {
    LazyVStack(spacing: 20) {
        ForEach(items) { item in
            ZStack {
                GeometryReader { geo in
                    item.bgColor
                        .offset(y: geo.frame(in: .named("scroll")).minY * 0.4)
                        .clipped()
                }
                .frame(height: 240)
            }
            .frame(height: 240)
            .clipShape(RoundedRectangle(cornerRadius: 20))
        }
    }
    .padding()
}
.coordinateSpace(name: "scroll")

Full implementation

The implementation below creates a scrollable list of destination cards where the gradient background and icon art move at different speeds — the icon shifts at 35 % of scroll velocity while the card frame stays fixed in layout. A shadow tinted to each card's accent colour reinforces the lifted look. All text labels carry accessibilityLabel annotations so VoiceOver readers get a combined description without reading the decorative icon.

import SwiftUI

// MARK: - Model

struct Destination: Identifiable {
    let id = UUID()
    let title: String
    let subtitle: String
    let gradient: [Color]
    let icon: String
}

// MARK: - Root View

struct ParallaxScrollView: View {
    let destinations: [Destination] = [
        Destination(title: "Mountains",  subtitle: "3,800 m above sea level",
                    gradient: [.blue, .indigo],    icon: "mountain.2.fill"),
        Destination(title: "Ocean",      subtitle: "Pacific depths await",
                    gradient: [.cyan, .teal],      icon: "water.waves"),
        Destination(title: "Forest",     subtitle: "Ancient redwoods",
                    gradient: [.green, .mint],     icon: "leaf.fill"),
        Destination(title: "Desert",     subtitle: "Sahara at golden hour",
                    gradient: [.orange, .red],     icon: "sun.max.fill"),
        Destination(title: "Arctic",     subtitle: "Aurora borealis nights",
                    gradient: [.purple, .indigo],  icon: "snowflake"),
    ]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 24) {
                ForEach(destinations) { destination in
                    ParallaxCard(destination: destination)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 16)
        }
        .coordinateSpace(name: "scroll")
        .navigationTitle("Explore")
    }
}

// MARK: - Card

struct ParallaxCard: View {
    let destination: Destination

    var body: some View {
        ZStack(alignment: .bottomLeading) {

            // ── Parallax background layer ──────────────────────────────
            GeometryReader { geo in
                let minY = geo.frame(in: .named("scroll")).minY
                let shift = minY * 0.35          // 35 % of scroll speed

                LinearGradient(
                    colors: destination.gradient,
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .overlay {
                    Image(systemName: destination.icon)
                        .resizable()
                        .scaledToFit()
                        .foregroundStyle(.white.opacity(0.22))
                        .padding(52)
                        .offset(y: shift)        // icon moves fastest
                }
                .offset(y: shift * 0.5)          // gradient moves at half icon speed
                .clipped()
            }
            .frame(height: 260)

            // ── Text overlay ───────────────────────────────────────────
            VStack(alignment: .leading, spacing: 6) {
                Text(destination.title)
                    .font(.title.bold())
                    .foregroundStyle(.white)
                    .accessibilityAddTraits(.isHeader)
                Text(destination.subtitle)
                    .font(.subheadline)
                    .foregroundStyle(.white.opacity(0.85))
            }
            .padding(20)
        }
        .frame(height: 260)
        .clipShape(RoundedRectangle(cornerRadius: 24))
        .shadow(color: destination.gradient[0].opacity(0.45), radius: 14, y: 7)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(destination.title): \(destination.subtitle)")
    }
}

// MARK: - Preview

#Preview {
    NavigationStack {
        ParallaxScrollView()
    }
}

How it works

  1. 1. .coordinateSpace(name: "scroll") — attaches a named coordinate space to the ScrollView. Every GeometryReader inside can then call .frame(in: .named("scroll")) to get its rectangle expressed in scroll-view coordinates rather than global screen coordinates, so the measurement automatically accounts for all parent offsets.
  2. 2. geo.frame(in: .named("scroll")).minY — returns the Y position of the card's top edge within the scroll view. When the card is at the very top of the viewport, minY is 0. As you scroll down, cards that are still below the fold have a positive minY; cards that have scrolled above the fold go negative. This live value drives the offset.
  3. 3. shift = minY * 0.35 — the multiplier controls perceived depth. At 0.35, the background travels at 35 % of the user's scroll speed; the card frame itself travels at 100 %, so the background appears to lag behind and "move through" the card window. Values below 0.2 look subtle; above 0.6 feel exaggerated.
  4. 4. Two-speed layering — the gradient receives shift * 0.5 while the icon art gets the full shift. Using different rates for foreground and background elements within the same card adds an additional depth layer without any extra views.
  5. 5. .clipped() inside GeometryReader, then .clipShape() on the ZStack — the inner .clipped() prevents the offset background from bleeding beyond the GeometryReader bounds. The outer .clipShape(RoundedRectangle(cornerRadius: 24)) rounds both the background and text overlay together in one pass, avoiding a double mask cost.

Variants

Variant 1 — Hero header that stretches on over-scroll

A large header image that both parallaxes and stretches when the user pulls past the top edge. The trick is clamping the height with max(320 + minY, 320) so it only grows, never shrinks below the design height.

struct HeroParallaxView: View {
    var body: some View {
        ScrollView {
            ZStack(alignment: .top) {
                // Stretching hero
                GeometryReader { geo in
                    let minY = geo.frame(in: .named("scroll")).minY
                    let heroHeight = max(320 + minY, 320)

                    LinearGradient(colors: [.purple, .blue],
                                   startPoint: .topLeading,
                                   endPoint: .bottomTrailing)
                    .frame(height: heroHeight)
                    .offset(y: -max(minY, 0))        // pin top edge to safe area
                    .overlay {
                        Image(systemName: "globe.americas.fill")
                            .resizable().scaledToFit()
                            .foregroundStyle(.white.opacity(0.2))
                            .padding(60)
                            .offset(y: minY * 0.45)  // parallax inside hero
                    }
                    .clipped()
                }
                .frame(height: 320)

                // Body content sits below hero
                VStack(spacing: 12) {
                    ForEach(0..<8) { _ in
                        RoundedRectangle(cornerRadius: 12)
                            .fill(Color.secondary.opacity(0.15))
                            .frame(height: 72)
                    }
                }
                .padding()
                .padding(.top, 290)
            }
        }
        .coordinateSpace(name: "scroll")
        .ignoresSafeArea(edges: .top)
    }
}

Variant 2 — iOS 17 visualEffect modifier (no GeometryReader in view tree)

.visualEffect(_:) (iOS 17+) lets you read geometry and apply render-tree effects without inserting a GeometryReader into the layout — ideal when you need the parallax on a single image without the ZStack boilerplate.

// Drop-in replacement for the background image in any card
Image(systemName: "leaf.fill")
    .resizable()
    .scaledToFill()
    .frame(height: 260)
    .visualEffect { content, proxy in
        let minY = proxy.frame(in: .named("scroll")).minY
        return content.offset(y: minY * 0.35)
    }
    .clipped()
    .clipShape(RoundedRectangle(cornerRadius: 24))

When to prefer it: use visualEffect for simple single-layer parallax. Stick with the explicit GeometryReader approach when you need different rates for multiple sublayers inside the same card.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement parallax scroll:

Implement parallax scroll in SwiftUI for iOS 17+.
Use GeometryReader and ScrollView with a named coordinate space.
Each card should have a background layer that offsets at 35% of scroll speed.
Support at least two layers (gradient + icon art) at different parallax rates.
Make it accessible (VoiceOver labels via accessibilityElement + accessibilityLabel).
Add a #Preview with five realistic sample destinations and realistic colours.

In Soarias, paste this prompt during the Build phase after your screen mockup is locked — Claude Code will scaffold the full view hierarchy and drop it into your target with zero boilerplate left for you to wire up.

Related guides

FAQ

Does this work on iOS 16?

The named-coordinate-space approach (.coordinateSpace(name:) + .frame(in: .named(_:))) works all the way back to iOS 14. The .visualEffect(_:) modifier variant is iOS 17 only. The #Preview macro requires Xcode 15+ regardless of deployment target, but you can swap it for a PreviewProvider struct if you need to stay on Xcode 14.

How do I tune the parallax speed?

Adjust the multiplier on minY * factor. A factor of 0.0 means the background is pinned (no parallax). 0.3–0.5 is the sweet spot for a natural depth effect on card layouts. 0.7+ becomes cinematic but may feel jarring on short cards; reserve it for large hero headers. Negative values reverse direction — useful for a counter-scroll "stuck" overlay effect.

What's the UIKit equivalent?

In UIKit you implement scrollViewDidScroll(_:) on a UIScrollViewDelegate, then read scrollView.contentOffset.y and manually apply a CGAffineTransform or layer.transform to each background view. SwiftUI's GeometryReader approach is significantly less code and handles safe-area insets automatically.

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

```