Skip to content

Scroll Visibility Reveal

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

Scroll Visibility Reveal, playing
Download .swift
Origin
Original work
Found by
scroll reveal · appear on scroll · fade in · slide in · visibility · sections · revelar al desplazar · aparecer · fundido · entrada · visibilidad · secciones

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))
}
}
}

scrollVisibilityReveal(threshold:distance:)

ParameterTypeDefault
thresholdDouble0.15
distanceCGFloat36

Copy this code into your project, or download the file.

ScrollVisibilityReveal.swift
// 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()
}