How to Build Parallax Scroll in SwiftUI
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.
.coordinateSpace(name: "scroll") — attaches a named coordinate space to
the
ScrollView. EveryGeometryReaderinside 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.
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,
minYis 0. As you scroll down, cards that are still below the fold have a positiveminY; cards that have scrolled above the fold go negative. This live value drives the offset. - 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.
Two-speed layering — the gradient receives
shift * 0.5while the icon art gets the fullshift. Using different rates for foreground and background elements within the same card adds an additional depth layer without any extra views. -
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
-
⚠
Using
.globalinstead of a named coordinate space..frame(in: .global)gives you the card's position on screen — which changes as the window moves or the keyboard appears — not its position within the scroll content. Always name the space on the exactScrollViewyou intend to track. -
⚠
Forgetting
.clipped()on the inner layer. The offset background extends beyond the card frame during scroll. Without.clipped()inside theGeometryReader, the image visibly overlaps adjacent cards — especially noticeable on the first and last items. -
⚠
Applying the offset to the entire card instead of only the background.
If you put
.offset(y: shift)on theZStackor the row itself, SwiftUI re-evaluates layout every frame and the card's tap target drifts from its visual position. Always offset only the decorative background layer. -
⚠
Performance with many high-res assets.
Each
GeometryReadertriggers a layout pass every scroll tick. Keep backgrounds as gradients, solid colours, or resizable SF Symbols rather than full-resolutionAsyncImagedownloads to avoid dropped frames at 120 Hz on ProMotion displays.
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.