SwiftUI ScrollView in iOS 26: scrollPosition, scrollTransition, and scrollTargetBehavior
Master SwiftUI ScrollView in iOS 26 with scrollPosition, scrollTransition, scrollTargetBehavior, contentMargins, paging, and Liquid Glass edge effects, all with runnable Xcode 26 code.
The modern SwiftUI ScrollView in iOS 26 replaces almost every ScrollViewReader workaround you wrote in iOS 16 with a small set of declarative modifiers: scrollPosition for two-way scroll binding, scrollTransition for per-item visual effects, scrollTargetBehavior for paging and snapping, and contentMargins for safe-area aware padding. This guide walks through every modifier with runnable Xcode 26 examples, then covers the gotchas that bite teams shipping to the App Store today.
scrollPosition(_:anchor:) gives you a two-way Binding to the visible item ID, so no more ScrollViewReader hacks.
scrollTargetBehavior(.paging) and .viewAligned snap to whole pages or laid-out items; pair with scrollTargetLayout() on the inner LazyHStack.
scrollTransition runs a closure with ScrollTransitionPhase so you can scale, fade, rotate, or 3D-tilt items as they enter and leave the viewport.
contentMargins(_:_:for:) insets the scroll content from the bar area while keeping indicators flush, replacing fragile .padding + .background stacks.
The iOS 26 scrollEdgeEffectStyle, scrollClipDisabled, and scrollBounceBehavior modifiers cover Liquid Glass edge fades, parallax overflow, and empty-content bounce control.
For programmatic scrolling to a specific anchor in long content, ScrollViewReader still exists, but in 95% of cases scrollPosition is the right tool.
How do I track scroll position in SwiftUI?
In iOS 17 and earlier, reading the currently-visible item required a fragile combination of ScrollViewReader, GeometryReader, and PreferenceKey. iOS 26 collapses all of that into a single modifier: scrollPosition(_:anchor:). You pass a Binding to an optional Hashable identifier (commonly your model's id) and SwiftUI keeps it in sync with whichever item is closest to the requested anchor (any of .top, .center, .bottom, or any UnitPoint). Critically, the binding is two-way: assign a new value programmatically and the scroll view animates to that item.
import SwiftUI
struct Article: Identifiable, Hashable {
let id: UUID = .init()
let title: String
}
struct PositionTrackingScroll: View {
@State private var visibleID: Article.ID?
private let articles: [Article] = (1...50).map { Article(title: "Article \($0)") }
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(articles) { article in
Text(article.title)
.frame(maxWidth: .infinity, minHeight: 120)
.background(.thinMaterial, in: .rect(cornerRadius: 16))
.id(article.id) // Required so scrollPosition can match.
}
}
.scrollTargetLayout() // Tells iOS 26 this is the layout to snap to.
}
.scrollPosition(id: $visibleID, anchor: .center)
.overlay(alignment: .topTrailing) {
if let id = visibleID,
let article = articles.first(where: { $0.id == id }) {
Text("Now reading: \(article.title)")
.padding(8)
.background(.regularMaterial, in: .capsule)
.padding()
}
}
}
}
Three things matter here. First, every child needs an explicit .id(...); without it SwiftUI can't tie the binding back to your model. Second, the parent layout (usually LazyVStack or LazyHStack) needs .scrollTargetLayout() so SwiftUI knows which container's children are the snap candidates. Third, the binding is optional: it can be nil on first appear before the scroll view has laid out, so always unwrap before using it for UI updates.
Honestly, I hit that nil-on-first-appear gotcha shipping a reading app last winter and spent an embarrassing half hour staring at "now reading: nothing." If you're driving an overlay or analytics call off the binding, default it or guard with if let from day one.
What is scrollTargetBehavior in SwiftUI?
scrollTargetBehavior tells SwiftUI where a scroll gesture should settle when the user lifts their finger. iOS 26 ships three built-in behaviors and a protocol you can implement for custom snapping. The defaults are .paging (full-screen pages, similar to the old TabView(.page)), .viewAligned (snaps to whichever child of a scrollTargetLayout is nearest), and .automatic (the system default of free-scrolling deceleration).
The most common combination, a horizontal carousel of cards that snaps to each card, is two lines of code:
containerRelativeFrame(.horizontal, count: 1, spacing: 16) sizes each card to the visible width of the scroll view minus the requested spacing, which is what gives you that "one card per page" look. Bump count to 2 or 3 and you get half-card peeks for free, far less code than building the same effect with GeometryReader in iOS 16.
For more control, implement ScrollTargetBehavior directly. The protocol gives you a single updateTarget(_:context:) requirement where you mutate the proposed target's rect. A common use case is snapping to the nearest multiple of a row height in a vertical timeline, which is hard to do otherwise.
How do scroll transitions work in SwiftUI?
scrollTransition runs a closure for each child of a scroll view, passing the child's current ScrollTransitionPhase (one of .topLeading, .identity, or .bottomTrailing). You return a transformed view: scale it, fade it, rotate it in 3D, or animate any other VisualEffect. SwiftUI interpolates between the phases as the item moves through the viewport, which produces the "App Store Today card unfurling" look without a single line of GeometryReader.
The .interactive configuration runs the closure continuously as the user drags. Use .animated when you want SwiftUI to snap between phases with a spring instead. phase.value is a Double in the range -1...1, perfect for driving rotation, parallax, or color shifts. If you've already learned the SwiftUI animation system (springs, keyframes, and transitions), scroll transitions slot in alongside it: they accept the same Animation values and respect withAnimation.
How do you make a paging ScrollView in SwiftUI?
So, if you want a full-screen pager (the kind TabView(selection:) with .tabViewStyle(.page) used to give you), combine .scrollTargetBehavior(.paging) with a horizontally-oriented ScrollView. The advantage over TabView is that you get true lazy loading, free access to scrollPosition for tracking the current page, and you can mix paging with vertical scrolling inside each page.
struct OnboardingPager: View {
@State private var pageID: Int? = 0
private let pages = Array(0..<5)
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(pages, id: \.self) { index in
OnboardingPage(index: index)
.containerRelativeFrame(.horizontal)
.id(index)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollPosition(id: $pageID)
.ignoresSafeArea()
.overlay(alignment: .bottom) {
PageIndicator(count: pages.count, current: pageID ?? 0)
.padding(.bottom, 40)
}
}
}
Two notes for production. Set .scrollBounceBehavior(.basedOnSize) if you don't want the pager to rubber-band when there's only one page (common during A/B tests where you ship 1 of N variants). And if your pages contain interactive controls, add .scrollClipDisabled() to the outer ScrollView so shadows and badges on a page can spill into the next page during the transition.
contentMargins and scroll indicators in iOS 26
Before iOS 17, padding a scroll view meant padding its content, which pushed the scroll indicators inward and made them look detached from the safe area. contentMargins(_:_:for:) fixes this by letting you specify margins independently for the content (.scrollContent) and the indicators (.scrollIndicators), or both at once with .automatic. Combined with the iOS 26 Liquid Glass tab bars, this is how you stop content being clipped behind the bar while indicators still align to the bar's edge.
.scrollIndicatorsFlash(onAppear: true) is a small but appreciated iOS 26 addition: it briefly flashes the indicators when the view first appears so users know the content is scrollable. Pair it with scrollIndicators(.hidden) if you want indicators only on interaction.
Liquid Glass scroll edge effects in iOS 26
iOS 26's design language extends to the edges of scroll views. The scrollEdgeEffectStyle modifier controls how content fades or blurs into the toolbar and tab bar. Use .soft for the default Liquid Glass blur, .hard for a sharp cutoff, or .automatic to let the system decide based on the toolbar style. If your app uses a custom navigation bar built from a reusable glass effect component, set the edge effect to match the bar's translucency.
The behavior pairs naturally with SwiftUI's NavigationStack and type-safe routing: the toolbar background, the scroll edge effect, and the Liquid Glass material all read from the same environment values, so flipping a single style cascades through the entire screen. Apple covers the full set of edge effect styles in the official SwiftUI ScrollView documentation.
How do I scroll to a specific item in SwiftUI?
Two options. The modern one (and the right answer 95% of the time) is to write to the scrollPosition binding inside a withAnimation block. SwiftUI handles the animation curve, the deceleration, and the alignment for you.
The legacy option, ScrollViewReader with proxy.scrollTo(id, anchor:), is still available and is necessary in two situations: when you need to scroll to a non-laid-out item inside a non-lazy stack, and when you need to scroll within a nested scroll view where scrollPosition doesn't apply. For everything else, prefer the binding-based approach. It composes with scrollTargetBehavior correctly; ScrollViewReader sometimes fights with paging.
ScrollView performance and lazy stacks
Performance issues with ScrollView in iOS 26 almost always come from one of three causes: using VStack instead of LazyVStack for long lists, fixed-height items with expensive bodies, or scroll transitions doing layout work. The fix for the first is obvious: swap to LazyVStack. For the second, mark expensive subviews with .drawingGroup() when they contain many Shape or gradient layers; SwiftUI then rasterizes the subtree into a single Metal texture. For the third, profile with Instruments' SwiftUI template and look for "long view body update" warnings.
The interaction between scroll views and gestures is also worth understanding. If a child view installs a SwiftUI drag gesture, the scroll view's pan gesture wins by default. Use simultaneousGesture or set scrollDisabled(true) while the child gesture is active. For deep tuning, Apple's "Beyond scroll views" WWDC session walks through the internal phase model and shows how to detect overscroll, end-of-content, and rubber-band states without violating gesture priority.
One last note. As of iOS 26.0, scrollPosition on macOS scroll views requires NSScrollView-backed rendering, which is the default in SwiftUI on macOS 15+. If you ship a Catalyst app, test the binding on actual macOS, because the macOS scroll view layout is computed differently and items report position based on their geometric center, not their frame origin. The detail is documented under the ScrollTargetBehavior protocol reference if you want to dig into the math.
Frequently Asked Questions
Is ScrollViewReader deprecated in iOS 26?
No. ScrollViewReader is still supported and still the right tool for scrolling within nested scroll views or to non-laid-out items. For most modern code, however, scrollPosition(_:anchor:) is simpler and composes better with paging and scroll transitions.
Why is my scrollPosition binding always nil?
The most common cause is forgetting .scrollTargetLayout() on the inner LazyVStack or LazyHStack, or forgetting to assign .id(item.id) to each child. Both are required for SwiftUI to identify which item the binding should track.
Can I use scrollTransition with non-lazy stacks?
Yes, but you lose the lazy creation benefit. scrollTransition works on any view inside a ScrollView; the phase calculation does not require LazyVStack. For very long lists, however, always prefer a lazy container so the transition closure only runs for views that exist.
How do I disable bouncing on a SwiftUI ScrollView?
Apply .scrollBounceBehavior(.basedOnSize) to disable bouncing when the content fits, or .scrollBounceBehavior(.always) to keep the bounce even on small content. The default .automatic follows the system convention per axis.
Does scrollTargetBehavior(.paging) replace TabView(.page) in iOS 26?
Functionally yes, and it's the recommended approach for new code. .paging on a ScrollView gives you lazy loading, a two-way scrollPosition binding, and full access to scroll transitions, none of which the paged TabView offered.
NavigationSplitView is SwiftUI's adaptive sidebar container. Learn selection bindings, deep linking, iPhone collapse behaviour, and accessibility patterns for iOS 26.
A hands-on walkthrough of the Xcode 26 SwiftUI preview crash on certain @Observable types, what the crash log really means, three workarounds that work today, and the bug report shape Apple triages fastest.
Why iOS 26's .glassEffect() modifier silently fails in real SwiftUI hierarchies, the four preconditions Apple's docs gloss over, and the exact GlassEffectContainer fix I used in production.