Scroll Visibility Reveal
Sections fade and slide into place the first time they become visible while the user scrolls.

- Scrolling
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- scroll reveal · appear on scroll · fade in · slide in · visibility · sections · revelar al desplazar · aparecer · fundido · entrada · visibilidad · secciones
Description
Section titled “Description”A low threshold means a section taller than the screen still gets revealed.
struct ScrollVisibilityRevealDemo: View { private struct Section: Identifiable { let id: String let symbol: String let colors: [Color] }
private let sections = [ Section(id: "Sunrise", symbol: "sunrise.fill", colors: [.orange, .pink]), Section(id: "Forest", symbol: "leaf.fill", colors: [.green, .teal]), Section(id: "Ocean", symbol: "water.waves", colors: [.cyan, .blue]), Section(id: "Night", symbol: "moon.stars.fill", colors: [.indigo, .purple]), Section(id: "Storm", symbol: "cloud.bolt.rain.fill", colors: [.gray, .indigo]), Section(id: "Snow", symbol: "snowflake", colors: [.mint, .cyan]), ]
@State private var position = ScrollPosition(y: 0) @State private var maxOffset: CGFloat = 0 @State private var cycle = 0 /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ScrollView { VStack(spacing: 18) { ForEach(sections) { section in card(section) .scrollVisibilityReveal() } } .padding(20) } // A new identity forgets what has been revealed, so the demo can replay it. .id(cycle) .scrollPosition($position) .onScrollGeometryChange(for: CGFloat.self) { geometry in max(geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom - geometry.containerSize.height, 0) } action: { _, newValue in maxOffset = newValue } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } await replay() } }
private func card(_ section: Section) -> some View { VStack(spacing: 12) { Image(systemName: section.symbol) .font(.largeTitle) .accessibilityHidden(true)
Text(section.id) .font(.title.bold()) } .foregroundStyle(.white) .frame(maxWidth: .infinity, minHeight: 240) .background(LinearGradient(colors: section.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 28)) }
// Only the demo scrolls by itself. Each pass scrolls to the end, then starts over. private func replay() async { var time = 0.0 while isAutoPlaying { try? await Task.sleep(for: .milliseconds(16)) guard !Task.isCancelled, isAutoPlaying else { return } time += 0.016 if time >= 4 { time = 0 cycle += 1 } position.scrollTo(y: maxOffset * min(time / 3.6, 1)) } }}API reference
Section titled “API reference”scrollVisibilityReveal(threshold:distance:)
| Parameter | Type | Default |
|---|---|---|
threshold | Double | 0.15 |
distance | CGFloat | 36 |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Fades and slides a view into place the first time it becomes visible in a scroll view.////// With Reduce Motion on, the view only fades in. With VoiceOver on, it is shown from the/// start, so a section that has not been reached yet can still be found.struct ScrollVisibilityReveal: ViewModifier { var threshold: Double var distance: CGFloat
@State private var hasAppeared = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
private var isVisible: Bool { hasAppeared || voiceOverEnabled }
func body(content: Content) -> some View { content .opacity(isVisible ? 1 : 0) .offset(y: isVisible || reduceMotion ? 0 : distance) .onScrollVisibilityChange(threshold: min(max(threshold, 0), 1)) { visible in guard visible, !hasAppeared else { return } withAnimation(reduceMotion ? .easeIn(duration: 0.2) : .smooth(duration: 0.6)) { hasAppeared = true } } }}
extension View { /// A low threshold means a section taller than the screen still gets revealed. func scrollVisibilityReveal(threshold: Double = 0.15, distance: CGFloat = 36) -> some View { modifier(ScrollVisibilityReveal(threshold: threshold, distance: distance)) }}
// MARK: - Demo
struct ScrollVisibilityRevealDemo: View { private struct Section: Identifiable { let id: String let symbol: String let colors: [Color] }
private let sections = [ Section(id: "Sunrise", symbol: "sunrise.fill", colors: [.orange, .pink]), Section(id: "Forest", symbol: "leaf.fill", colors: [.green, .teal]), Section(id: "Ocean", symbol: "water.waves", colors: [.cyan, .blue]), Section(id: "Night", symbol: "moon.stars.fill", colors: [.indigo, .purple]), Section(id: "Storm", symbol: "cloud.bolt.rain.fill", colors: [.gray, .indigo]), Section(id: "Snow", symbol: "snowflake", colors: [.mint, .cyan]), ]
@State private var position = ScrollPosition(y: 0) @State private var maxOffset: CGFloat = 0 @State private var cycle = 0 /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ScrollView { VStack(spacing: 18) { ForEach(sections) { section in card(section) .scrollVisibilityReveal() } } .padding(20) } // A new identity forgets what has been revealed, so the demo can replay it. .id(cycle) .scrollPosition($position) .onScrollGeometryChange(for: CGFloat.self) { geometry in max(geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom - geometry.containerSize.height, 0) } action: { _, newValue in maxOffset = newValue } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } await replay() } }
private func card(_ section: Section) -> some View { VStack(spacing: 12) { Image(systemName: section.symbol) .font(.largeTitle) .accessibilityHidden(true)
Text(section.id) .font(.title.bold()) } .foregroundStyle(.white) .frame(maxWidth: .infinity, minHeight: 240) .background(LinearGradient(colors: section.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 28)) }
// Only the demo scrolls by itself. Each pass scrolls to the end, then starts over. private func replay() async { var time = 0.0 while isAutoPlaying { try? await Task.sleep(for: .milliseconds(16)) guard !Task.isCancelled, isAutoPlaying else { return } time += 0.016 if time >= 4 { time = 0 cycle += 1 } position.scrollTo(y: maxOffset * min(time / 3.6, 1)) } }}
#Preview { ScrollVisibilityRevealDemo()}