← All shorts
09iOS 17+2 min read

scrollTransition: Per-Item Effects Driven by Scroll Phase

Scale and fade carousel items as they enter and leave the viewport, without measuring frames yourself.

Apple Music's now-playing carousel scales down items as they drift off-center. Building that by hand meant measuring each item's frame against the screen with GeometryReader, computing a distance, and mapping it to a scale factor on every scroll tick.

#The old way

struct OldFadingCard: View {
    var body: some View {
        GeometryReader { geometry in
            let midX = geometry.frame(in: .global).midX
            let screenMidX = UIScreen.main.bounds.midX
            let distance = abs(midX - screenMidX)
            let scale = max(0.85, 1 - distance / 1000)

            RoundedRectangle(cornerRadius: 16)
                .fill(.orange.gradient)
                .scaleEffect(scale)
        }
        .frame(width: 200, height: 200)
    }
}

#The new way

import SwiftUI

struct AlbumArt: Identifiable {
    let id = UUID()
    let title: String
}

struct NowPlayingCarousel: View {
    let albums: [AlbumArt] = [
        AlbumArt(title: "Midnight Drive"),
        AlbumArt(title: "Analog Dreams"),
        AlbumArt(title: "Static & Silence"),
        AlbumArt(title: "Neon Skyline")
    ]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 24) {
                ForEach(albums) { album in
                    RoundedRectangle(cornerRadius: 24)
                        .fill(.purple.gradient)
                        .overlay {
                            Text(album.title)
                                .font(.headline)
                                .foregroundStyle(.white)
                        }
                        .frame(width: 240, height: 240)
                        .scrollTransition { content, phase in
                            content
                                .scaleEffect(phase.isIdentity ? 1 : 0.85)
                                .opacity(phase.isIdentity ? 1 : 0.4)
                        }
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .contentMargins(.horizontal, 40, for: .scrollContent)
    }
}

phase.isIdentity is true while the item sits in its resting position and false as it scrolls toward either edge, so the closure only needs two states to describe the whole animation.

#Why it matters

  • SwiftUI measures each item's position for you and calls the closure with a ScrollTransitionPhase, no UIScreen bounds math required.
  • The effect recomputes continuously as the user drags, not just on settle, so it tracks the gesture 1:1.
  • Composable with any visual effect — scale, opacity, rotation3DEffect, blur — inside the same closure.

#Gotcha

The axis parameter defaults to .horizontal. Attach .scrollTransition inside a vertical ScrollView without explicitly passing axis: .vertical, and the phase never reflects the actual scroll motion — the effect looks frozen or applies at the wrong moment, because SwiftUI is measuring movement along the wrong axis entirely.

swiftuiios17scrolling

Related shorts