A black vinyl record with a plain red label sliding out of a worn, unprinted cardboard sleeve, with a crate of records blurred in the background under warm amber light

Moth Into Flame: SwiftUI visualEffect Parallax Without State

VinylCrate's home-screen parallax used the modern scroll API and still re-ran the whole collection's body on every frame. Moving it into visualEffect fixed that. A second effect built the same way never made it past the device.

The parallax on VinylCrate’s home screen never touched GeometryReader. It used onScrollGeometryChange, which is the API you’re supposed to reach for now. It still re-ran the body of the biggest view on that screen on every frame of scrolling.

That’s the part most visualEffect write-ups skip. GeometryReader was never the real tax. The real cost is a value that changes every frame landing in state on a view that has a lot of work to do. Whatever API produced the number, once it lands in @State on the wrong view, you’ve signed up for a body evaluation per frame.

Here’s how I moved that parallax to the render layer. I’ll also cover a second effect I built the same way and deleted three weeks later, the one scroll effect in the app that still uses state on purpose, and a Bool that can leave a toggle disabled forever.

Harvester of Sorrow — The Scroll Offset That Rebuilt the Home Screen

In March, VinylCrate’s featured-album card got parallax. The artwork slides down at 30% of scroll speed and stops at 40 points. The image is drawn 1.4 times the card’s height, so there’s extra artwork outside the visible crop for it to slide into.

Here’s how it was wired. CollectionScrollView, the view behind the entire home screen, owned the offset:

@State private var heroScrollOffset: CGFloat = 0
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, offset in
    heroScrollOffset = offset
}

It passed that value to FeaturedAlbumView through a scrollOffset: parameter, and the card turned it into an offset:

let parallaxOffset = scrollOffset > 0 ? min(scrollOffset * 0.3, 40) : 0
.offset(y: parallaxOffset)

None of those lines are wrong on their own. The problem is how they fit together.

onScrollGeometryChange splits its work into a transform and an action, and the action only runs when the transformed value changes. That works great when the transform turns scrolling into something coarse, like a Bool. This transform handed back contentOffset.y untouched, and during a scroll that value changes every frame. So every frame wrote heroScrollOffset, and every write invalidated the view that owned it.

The view that owned it was the whole collection screen: the LazyVStack with every section of the grid, plus the alphabetical index overlay. All of it was re-evaluated and diffed against the previous tree so one image could move a few points.

I’ve written about keeping body re-evaluations rare and finding the ones you didn’t expect. This one is easy to miss because the code looks modern. In the GeometryReader days you could at least see the cost: a greedy container, sizes fed in by hand, an onChange writing state. With onScrollGeometryChange the code is three lines and tidy, but the structure underneath hasn’t changed. A parent still takes a state write on every frame.

Chasing Light — Parallax at the Render Layer

In May I moved it into visualEffect. The commit message sums it up:

This avoids body rebuilds on every scroll frame; the transform runs at the render layer.

The @State came off CollectionScrollView. The onScrollGeometryChange went with it, and so did the scrollOffset: parameter. Here’s what replaced all three, trimmed of the matched-geometry modifier and design tokens:

private func parallaxBackground(height: CGFloat) -> some View {
    CachedAsyncImage(url: detail.bestImageURL) { image in
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
            .visualEffect { content, geometry in
                let amount = -geometry.frame(in: .scrollView).minY
                let parallax = amount > 0 ? min(amount * 0.3, 40) : 0
                return content.offset(y: parallax)
            }
    } placeholder: {
        Rectangle()
            .fill(AppColors.separator.opacity(0.3))
    }
    .frame(height: height)
    .clipped()
}

Same math, same 30%, same 40-point cap. The difference is where it runs.

Here’s the declaration in the SDK:

nonisolated public func visualEffect(
    _ effect: @escaping @Sendable (EmptyVisualEffect, GeometryProxy) -> some VisualEffect
) -> some View

The first argument is a placeholder for the view you’re modifying. You don’t build views with it. You chain effects off it and return the result. The second is a GeometryProxy for the modified view itself: its size, and its frame in whatever coordinate space you ask for. It’s the same proxy GeometryReader hands you, without a container that takes over your layout.

Apple describes a VisualEffect as changing “the visual appearance of a view without changing its ancestors or descendents,” and that’s the whole contract. Everything you can call on the placeholder is a render-time transform: offset, scale, rotation, opacity, blur, color adjustments, shaders. None of it can resize the view, and nothing around it moves to make room. That’s why it’s safe to compute from geometry. The classic scroll-effect feedback loop goes geometry to state, state to body, body to layout, and layout back to geometry. An effect that can’t change layout can’t trigger another layout pass, so the loop never starts.

There’s no state anywhere in that picture either. FeaturedAlbumView’s body runs, sets up the closure, and stays out of it. SwiftUI re-runs the closure as the image’s geometry changes, and nothing is invalidated or diffed along the way. CollectionScrollView doesn’t know the parallax exists.

Two details make this particular effect work.

There’s hidden image to slide. The artwork is .fill, so it overflows its frame. The 1.4× height gives it room, and .clipped() trims what’s outside. Offsetting the image pulls pixels that were already cropped into view. Without that slack, an offset just opens a gap at the edge. Remember that, because it decides the next effect.

The card measures itself. The old version read the scroll view’s contentOffset, which only matched “how far past the hero” because the hero happened to sit at the top of the content. The new version measures the image’s own frame against the nearest scroll view. amount goes positive once the image’s top edge passes above the scroll view’s top edge, and only then does the offset kick in. The card no longer depends on where it sits.

Thorn Within — The Capture List Reduce Motion Needed

In September I gated that parallax on Reduce Motion. It was the only motion in the app that ignored the setting, and it sat on the first screen people see. Parallax is one of the classic triggers for motion sensitivity, so that was a bad one to miss.

The obvious change doesn’t compile:

.visualEffect { content, geometry in
    let amount = -geometry.frame(in: .scrollView).minY
    let parallax = amount > 0 && !reduceMotion ? min(amount * 0.3, 40) : 0
    return content.offset(y: parallax)
}

Go back to the signature: an @Sendable closure on a nonisolated function. The closure doesn’t run on the main actor, and reduceMotion is an @Environment property on a main-actor view. VinylCrate also builds with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so everything in the module is main-actor unless it says otherwise. The compiler won’t let a Sendable closure reach back into main-actor state.

What ships is a capture list:

.visualEffect { [reduceMotion] content, geometry in
    let amount = -geometry.frame(in: .scrollView).minY
    let parallax = amount > 0 && !reduceMotion ? min(amount * 0.3, 40) : 0
    return content.offset(y: parallax)
}

The capture runs when body builds the closure, and body runs on the main actor, so the copy is legal. After that the closure only ever sees a Bool. Reading the environment value in body also keeps the dependency. When someone flips Reduce Motion, body runs again, builds a new closure, and the new closure captures the new value. You don’t need any observation code for that.

The same rule covers helpers. With default main-actor isolation, a plain helper type or a private func on the view is main-actor isolated too, and the closure can’t call it. Anything the closure reads that isn’t a local or a capture has to be explicitly nonisolated. The parallax math is inline, so it never hit this. The next effect did.

Notice the shape of the Reduce Motion check, too. parallax is either the real number or 0, and .offset(y:) stays in the chain either way. That’s not just style. The closure returns some VisualEffect, an opaque type, so every path has to produce the same concrete chain. This won’t compile:

.visualEffect { [reduceMotion] content, geometry in
    if reduceMotion {
        return content.opacity(1)
    } else {
        return content.offset(y: -geometry.frame(in: .scrollView).minY * 0.3).opacity(1)
    }
}

Branch on values, not modifiers. When an effect should disappear, give it its identity value: offset 0, scale 1, blur 0. The shader effects take an isEnabled: parameter for the same reason.

Slither — Drawing the Record Out of the Sleeve

The same September change also touched the album detail screen. I wanted its hero to respond to scroll too, and parallax was the obvious first idea. It doesn’t work there.

FeaturedAlbumView’s parallax works because the image is .fill inside a fixed-height, clipped frame, so there’s hidden image to slide. The detail hero is .scaledToFit() with no clip. The artwork fills its frame exactly. Offset it and you expose the black background behind it. The only way to create slack would have been to crop the cover, and on a vinyl app the cover art is the whole point. Cropping it wasn’t an option.

So I drew the record out of the sleeve instead. A vinyl disc sat behind the cover in AlbumHeroHeader, and as you scrolled, it slid out to the right and turned:

nonisolated private enum Geometry {
    static let restingReveal: CGFloat = 20
    static let maxReveal: CGFloat = 56
    static let travel: CGFloat = 200
    static let rotationDegrees: Double = 16
    static let discInset: CGFloat = 10
}
VinylDiscView(
    artistName: detail.primaryArtistName,
    trackTitle: detail.title,
    labelColor: VinylLabelColor.forArtist(detail.primaryArtistName)
)
    .padding(Geometry.discInset)
    .visualEffect { [reduceMotion] content, geometry in
        let scrolled = max(0, -geometry.frame(in: .scrollView).minY)
        let progress = reduceMotion ? 0 : min(scrolled / Geometry.travel, 1)
        return content
            .offset(x: Geometry.restingReveal + progress * Geometry.maxReveal)
            .rotationEffect(.degrees(progress * Geometry.rotationDegrees))
    }
    .accessibilityHidden(true)

That nonisolated on the enum is the helper rule from the last section. Without it, under default main-actor isolation, Geometry is main-actor isolated and the closure can’t read its constants.

The disc was attached as a .background(alignment: .center) of the sleeve, not as a ZStack sibling, so the sleeve decided the size and the disc just lived behind it. The math maps the first 200 points of scroll onto a progress from 0 to 1. The disc rests 20 points out so there’s a hint of it at the top, then travels up to 56 more and rotates up to 16 degrees. Reduce Motion pins progress at zero, which leaves the resting reveal and stops all motion. Values again, not modifiers.

Rotation came from scroll offset rather than a TimelineView. A spinning record is the obvious version, and it would mean a timer running on a screen people sit on while they read liner notes. Driving it from scroll meant the disc moved when your thumb moved and not otherwise.

Too Far Gone? — The Effect That Never Read

On September 23 the disc came back out. Here’s the note I left:

On device the disc only ever showed as a sliver at the screen edge. On iPhone
the sleeve fills the width minus a 16pt margin, so the disc's travel went
off-screen and the effect never read.

That’s the whole story. The sleeve is nearly full-width on a phone, so a disc sliding out to the right has nowhere to go but past the edge of the screen. Those constants were first-pass numbers I planned to tune on a real phone. Once I saw it on a real phone, there was nothing worth tuning.

The refactors that came with it stayed. So did the Reduce Motion gate on the home screen. Only the disc left, and leaving was cheap. No state to unwind and no parameters threaded through parents. One closure and one enum came out of one file. visualEffect made the effect cheap to build, and it made it just as cheap to delete.

Mama Said — When Scroll State Is the Right Call

Not everything belongs in visualEffect. The album detail screen has one scroll effect that stays in state, and it should.

The back, favorite, and crate buttons float over the artwork in an .overlay(alignment: .top). As the hero scrolls up under them, a frosted backdrop fades in behind the buttons. It’s a linear ramp over 44 points, clamped between 0 and 0.6. The ceiling is there because .ultraThinMaterial is already SwiftUI’s lightest material, so capping the opacity is the only way to make it lighter.

visualEffect can’t do this, for a structural reason. The closure only knows the geometry of the view it modifies. The chrome sits outside the scroll content, so its own frame never moves when you scroll. Put a visualEffect on it and the closure sees the same rectangle every frame. The thing the fade depends on, the hero’s bottom edge reaching the bar, lives in a different view entirely. This one really does need state.

So the question becomes where the state lives. Not on the screen:

@MainActor
@Observable
final class AlbumDetailChromeModel {

    nonisolated static let maxBarOpacity = 0.6

    private(set) var barOpacity: Double = 0

    private(set) var heroHeight: CGFloat = 0

    nonisolated static func opacity(
        scrollOffset: CGFloat,
        heroHeight: CGFloat,
        barHeight: CGFloat,
        fadeDistance: CGFloat = 44
    ) -> Double {
        guard heroHeight > 0, fadeDistance > 0 else { return 0 }
        let threshold = heroHeight - barHeight
        let progress = (scrollOffset - threshold) / fadeDistance
        return Double(min(max(progress, 0), 1)) * maxBarOpacity
    }

    func updateHeroHeight(_ height: CGFloat) {
        guard height != heroHeight else { return }
        heroHeight = height
    }

    func update(scrollOffset: CGFloat, barHeight: CGFloat) {
        let newValue = Self.opacity(
            scrollOffset: scrollOffset,
            heroHeight: heroHeight,
            barHeight: barHeight
        )
        guard newValue != barOpacity else { return }
        barOpacity = newValue
    }
}

AlbumDetailView owns it, feeds it, and hands it to the chrome. Trimmed to the lines that matter:

@State private var chrome = AlbumDetailChromeModel()

var body: some View {
    ScrollView {
        VStack(spacing: 0) {
            AlbumHeroHeader(detail: detail) { chrome.updateHeroHeight($0) }
        }
    }
    .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, offset in
        chrome.update(scrollOffset: offset, barHeight: Spacing.navBarHeight)
    }
    .overlay(alignment: .top) {
        AlbumDetailTopChrome(
            chrome: chrome,
            isFavorite: isFavorite,
            showsDelete: onDelete != nil,
            onBack: { dismiss() },
            onToggleFavorite: { container?.collection.toggleFavorite(detail) },
            onAddToCrate: { showingCrateOptions = true },
            onDelete: { showingDeleteConfirmation = true }
        )
    }
}

Ownership does the important work here. AlbumDetailView.body holds the model but never reads barOpacity. Only AlbumDetailTopChrome reads it, so a change invalidates that one small view and nothing else. That matters because AlbumDetailView is enormous. The SwiftLint header at the top of the file talks about splitting “the 1300-line body.” Putting this value in that view’s @State would recreate the home screen’s problem on the screen that can least afford it.

The clamp does the rest. opacity returns 0 before the threshold and maxBarOpacity past the fade band, so the value only changes inside those 44 points. Outside the band, the onScrollGeometryChange action still runs every frame, but it computes the same number every time, and the guard newValue != barOpacity turns that into no write at all. The guard is extra insurance. As I found in the last Observation post, @Observable’s generated setter already skips notification when an Equatable value comes in equal. I keep it anyway because it makes the intent obvious in the code instead of leaving it to whatever the macro generates.

Adding contentInsets.top to the offset normalizes it so zero means “at rest,” whatever the safe area is doing. The ramp is a nonisolated static func, which makes it pure arithmetic you can unit test without standing up a scroll view. With default main-actor isolation, that keyword has to be spelled out.

One more detail from the chrome is worth stealing:

.background {
    Color.clear
        .background(.ultraThinMaterial)
        .opacity(chrome.barOpacity)
        .ignoresSafeArea(edges: .top)
        .allowsHitTesting(false)
}

Even at zero opacity, that strip swallowed drags in the gaps between the buttons, which made the top of the page unscrollable. Invisible isn’t the same as absent.

Sleepwalk My Life Away — The Bool That Never Changes

The usual advice for scroll state is to quantize it: have the transform return something coarse, like a Bool, so the action fires twice per round trip instead of every frame. That advice is right. It also has a trap, and CRX’s onboarding is written to avoid it.

The disclaimer step won’t enable its accept toggle until you’ve scrolled to the bottom of the legal copy. Here’s the obvious version, the one CRX doesn’t use:

.onScrollGeometryChange(for: Bool.self) { geometry in
    Self.hasReachedBottom(geometry: geometry)
} action: { _, reachedBottom in
    if reachedBottom {
        hasScrolledToBottom = true
    }
}

The action only fires when the transformed value changes. On a phone, the disclaimer is taller than its container, so the Bool starts false, flips to true when you reach the end, and the toggle enables.

Now run it where the content fits without scrolling, like macOS or a large iPad. The Bool is true on the very first layout pass. It never changes after that, so the action never fires and the toggle stays disabled forever. The user did everything right. There was nothing to scroll.

The fix is to stop quantizing in the transform. Observe the whole ScrollGeometry and evaluate the predicate in the action:

.onScrollGeometryChange(for: ScrollGeometry.self) { geometry in
    geometry
} action: { _, geometry in
    if Self.hasReachedBottom(geometry: geometry) {
        hasScrolledToBottom = true
    }
}
static func hasReachedBottom(geometry: ScrollGeometry) -> Bool {
    geometry.contentOffset.y + geometry.containerSize.height
        >= geometry.contentSize.height - 24
}

The geometry always changes at least once, from its zero initial value to the first real layout, so the action fires on first layout too. If the content already fits, the predicate passes immediately. The 24-point tolerance means nobody has to land on the last pixel.

So here’s the more precise version of the rule. Quantizing is still right, but think about the first layout. A Bool that starts false and only flips when the user scrolls, like a header collapse threshold, is fine. A Bool that can already be true at first layout is the trap.

Better Than You — When scrollTransition Is the Right Tool

Before reaching for geometry at all, ask whether you need it. scrollTransition uses the same VisualEffect machinery and the same @Sendable closure, but it gives you a phase instead of a proxy. Here’s a general illustration, not app code:

AlbumCard(album: album)
    .scrollTransition(.interactive.threshold(.visible(0.6))) { content, phase in
        content
            .opacity(1 - abs(phase.value) * 0.5)
            .scaleEffect(phase.isIdentity ? 1 : 0.95)
    }

phase.value runs from -1 (leaving past the top or leading edge) through 0 (identity) to 1 (leaving past the bottom or trailing edge). The threshold decides what counts as fully visible. Here, a card is at identity once 60% of it is on screen. Swap .interactive for .animated and the transition snaps between phases with an animation instead of tracking the finger.

When the effect is about visibility, scrollTransition is the better tool. Cards that fade as they leave either edge are symmetric, and SwiftUI already knows the answer. That means less code and no coordinate-space decisions.

Reach for visualEffect when the effect is defined in points. VinylCrate’s parallax is 30% of distance scrolled, capped at 40 points, and it only reacts to the top edge. The disc mapped 200 points of travel onto a progress value. Neither is a visibility question. The same goes for anything that needs the view’s own size, or measures against a named container or a specific axis. visualEffect also works anywhere there’s geometry, while scrollTransition only works inside a scrolling container.

Spit Out the Bone — Where visualEffect Bites

Only VisualEffect methods are allowed. On iOS that’s offset (a CGSize or x:y:), opacity, three scaleEffect overloads, rotationEffect, rotation3DEffect, transformEffect (affine or projection), blur, brightness, contrast, saturation, grayscale, hueRotation, blendMode, and the shader effects colorEffect, distortionEffect, and layerEffect. If you’ve seen offset(z:), transform3DEffect, or perspectiveRotationEffect in examples, those are visionOS. Nothing that sizes or lays out content is on the list: no frame, padding, or font. shadow and clipShape aren’t there either. If one of those has to respond to scroll, you’re in state territory, and the chrome model above is the shape I’d use.

Layout never finds out. That’s the point of the API, and it’s also the trap. The parallax image offset 40 points still occupies its original frame. The disc rotated 16 degrees still had the frame it started with. Neighbors don’t make room. If something should push its siblings around, visualEffect is the wrong tool, and no amount of offset math will fake it convincingly.

Don’t assume the tap target followed the pixels. Hit testing is based on layout, and a visual effect is explicitly not layout. I haven’t found Apple documentation that pins down hit testing for visualEffect transforms, so measure it on the OS versions you ship rather than trusting me. Then design so the answer doesn’t matter. Keep big offsets on decorative layers. VinylCrate’s parallax moves artwork inside a clipped card, and the card, which is the button, stays where layout put it. The disc was accessibilityHidden and never interactive.

Pick the coordinate space on purpose. .global is the window, and it breaks first. Sheets, resizable iPad windows, and anything above the scroll view all leak into your “distance from the top.” .scrollView is the innermost containing scroll view, which is what both VinylCrate effects use, and it’s usually right until you nest a horizontal shelf inside a vertical feed. .scrollView(axis: .vertical) resolves to the innermost scroll view on that axis. GeometryProxy.bounds(of:) gives you that container’s bounds in the view’s local space when you need the viewport size as well as your position. For anything else, name the container and measure against it. Named coordinate spaces work the same way here as everywhere else.

Keep the closure to arithmetic. It runs for every visible view as geometry changes. No formatters, no allocations, no lookups, nothing that belongs in a model. Both VinylCrate closures are a handful of multiplications and a min. That’s the budget.

blur isn’t free. It renders offscreen, per view. One blurred hero is fine. A blur on every card in a dense list is the first thing you’ll feel on older hardware. If the frame budget gets tight, opacity and scale usually carry the effect on their own.

Measure it. Turn on the SwiftUI instrument, scroll, and count body updates for the screen’s parent view. Do it before the change and after. That comparison is the whole argument for this API, and it’s more convincing than anything I can write.

Orion — Pixels, Not State

Here’s the rule I’ve landed on. If an effect only moves pixels and can see the geometry it depends on, it belongs in visualEffect or scrollTransition, and it never touches state. If it changes layout, or it lives outside the scroll content it reacts to, it gets state. Clamp or quantize that state before it’s written, check what the first layout hands you, and keep it in the smallest view that reads it.

The scroll API you choose doesn’t decide the cost. Where the number lands does. VinylCrate’s home screen used the newest scroll API there is and still paid for a full collection body on every frame, because the number landed on the wrong view. Now the parallax runs at the render layer, and the home screen’s body never hears about it.

The disc is the other half. The architecture was right: no state, no timer, nothing a parent had to know about. That told me what the effect cost. It didn’t tell me whether anyone could see it. The phone answered that one.

Keep shipping.

Stay in the loop

Deep dives on Swift, SwiftUI, and building real apps — from the code to the App Store. No fluff, no toy projects.