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.