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, noUIScreenbounds 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.