← 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 items down as they drift off-center.

Building that by hand used to mean wrapping every card in a GeometryReader, measuring its frame against the screen, computing a distance from center, then mapping that distance onto a scale factor on every scroll tick. Every card. Every 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 reads true while the item sits in its resting position and false as it scrolls toward either edge, and those two states describe the whole animation.

#Why it matters

SwiftUI measures each item's position for you and calls the closure with a ScrollTransitionPhase, so every line of UIScreen bounds math from the old version disappears. The effect also recomputes continuously as the user drags, not just on settle, which is why it tracks the gesture 1:1.

Composition is free. Scale, opacity, rotation3DEffect, blur: any visual effect works inside that same closure.

#Gotcha

The axis parameter defaults to .horizontal. Attach .scrollTransition inside a vertical ScrollView, forget axis: .vertical, and SwiftUI measures movement along the wrong axis entirely, so the phase never reflects the actual scroll motion. The effect looks frozen. Or it fires at the wrong moment.

swiftuiios17scrolling

Related shorts