What You Will Build

A vertical scrolling list of colored cards with a progress bar at the top that fills as you scroll. Each card also applies a subtle 3D rotation based on its position in the viewport. This combines two popular patterns: scroll-linked progress bars (common in article readers) and perspective card effects.

Why This Pattern Matters

Scroll progress indicators help users gauge how much content remains. Combined with 3D card rotations, this pattern makes lists feel dynamic and premium. The implementation teaches PreferenceKey, coordinateSpace, and GeometryReader — three foundational SwiftUI layout tools.

Key SwiftUI Concepts

  • PreferenceKey passes scroll offset data from child to parent.
  • .coordinateSpace(name:) establishes a reference frame for position calculations.
  • .rotation3DEffect tilts cards based on their vertical position.

Step 1: Define a PreferenceKey for Scroll Offset

private struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

Step 2: Build the Progress Bar

@State private var scrollProgress: CGFloat = 0

GeometryReader { _ in
    Capsule()
        .fill(.gray.opacity(0.2))
        .overlay(alignment: .leading) {
            Capsule()
                .fill(.blue.gradient)
                .frame(width: max(0, scrollProgress * UIScreen.main.bounds.width))
        }
}
.frame(height: 4)

Step 3: Cards with 3D Rotation and Progress Tracking

ScrollView(.vertical) {
    LazyVStack(spacing: 16) {
        ForEach(0..<20, id: \.self) { index in
            GeometryReader { proxy in
                let minY = proxy.frame(in: .named("SCROLL_INDICATOR")).minY
                RoundedRectangle(cornerRadius: 16)
                    .fill(colors[index % colors.count].gradient)
                    .overlay {
                        Text("Card \(index + 1)")
                            .font(.title2.bold())
                            .foregroundStyle(.white)
                    }
                    .rotation3DEffect(
                        .degrees(Double(minY - 100) / 10),
                        axis: (x: 1, y: 0, z: 0),
                        perspective: 0.5
                    )
            }
            .frame(height: 120)
        }
    }
    .padding()
    .background(
        GeometryReader { proxy in
            Color.clear.preference(
                key: ScrollOffsetPreferenceKey.self,
                value: proxy.frame(in: .named("SCROLL_INDICATOR")).minY
            )
        }
    )
}
.coordinateSpace(name: "SCROLL_INDICATOR")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
    let totalHeight = CGFloat(20 * 136) - UIScreen.main.bounds.height
    scrollProgress = min(max(-value / totalHeight, 0), 1)
}

Tips and Pitfalls

  • Total height calculation: Each card is 120pt + 16pt spacing = 136pt. Multiply by the item count and subtract the screen height to get the scrollable range.
  • Clamp the progress: Use min(max(..., 0), 1) to keep the progress bar between 0% and 100%, especially during bounce scrolling.
  • Perspective parameter: A value of 0.5 gives a natural perspective. Lower values create more dramatic distortion.
  • Named coordinate spaces: Always use a unique string for .coordinateSpace(name:) to avoid conflicts with other geometry readers.
  • Performance with LazyVStack: LazyVStack only creates views as they scroll into view. Never use a regular VStack for 20+ items.

iOS Version: iOS 15+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.