Skip to content

Scroll Progress Bar

A thin bar pinned to the top of a scroll view that grows with the reading progress of the content.

Scroll Progress Bar, playing
Download .swift
Origin
Original work
Found by
scroll progress · reading progress · progress bar · article · scroll indicator · top bar · progreso de lectura · barra de progreso · desplazamiento · artículo · indicador · lectura

A thin bar pinned to the top of a scroll view that grows with the reading progress.

Apply it to the ScrollView with .scrollProgressBar(). The bar follows the scroll position directly, so it has no animation of its own.

struct ScrollProgressBarDemo: View {
@State private var position = ScrollPosition(y: 0)
@State private var maxOffset: CGFloat = 0
/// Only for this demo: it plays by itself until someone touches it.
@State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let paragraph = "Good interfaces explain themselves. A small sign of how much is left can turn a long read into something that feels easy to finish, and it asks nothing of the reader."
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("The long read")
.font(.largeTitle.bold())
ForEach(0..<14, id: \.self) { index in
Text("Chapter \(index + 1)")
.font(.title2.bold())
Text(paragraph)
.font(.body)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
}
.scrollPosition($position)
.scrollProgressBar()
.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 autoScroll()
}
}
// Only the demo scrolls by itself: down for most of the loop, then quickly back up.
private func autoScroll() async {
var time = 0.0
while isAutoPlaying {
try? await Task.sleep(for: .milliseconds(16))
guard !Task.isCancelled, isAutoPlaying else { return }
time = (time + 0.016).truncatingRemainder(dividingBy: 3)
let fraction = time < 2.4 ? time / 2.4 : (3 - time) / 0.6
position.scrollTo(y: maxOffset * fraction)
}
}
}

scrollProgressBar(tint:height:)

ParameterTypeDefault
tintLinearGradientLinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing)
heightCGFloat5

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

ScrollProgressBar.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum ScrollProgressBarMath {
/// How far through the content the reader is, from 0 to 1.
static func progress(offsetY: CGFloat, topInset: CGFloat, bottomInset: CGFloat, contentHeight: CGFloat, containerHeight: CGFloat) -> CGFloat {
let scrollable = contentHeight + topInset + bottomInset - containerHeight
guard scrollable > 0 else { return 0 }
return min(max((offsetY + topInset) / scrollable, 0), 1)
}
}
/// A thin bar pinned to the top of a scroll view that grows with the reading progress.
///
/// Apply it to the `ScrollView` with `.scrollProgressBar()`. The bar follows the scroll
/// position directly, so it has no animation of its own.
struct ScrollProgressBar: ViewModifier {
var tint: LinearGradient
var height: CGFloat
@State private var progress: CGFloat = 0
func body(content: Content) -> some View {
content
.onScrollGeometryChange(for: CGFloat.self) { geometry in
ScrollProgressBarMath.progress(
offsetY: geometry.contentOffset.y,
topInset: geometry.contentInsets.top,
bottomInset: geometry.contentInsets.bottom,
contentHeight: geometry.contentSize.height,
containerHeight: geometry.containerSize.height
)
} action: { _, newValue in
progress = newValue
}
.overlay(alignment: .top) { bar }
}
private var bar: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule().fill(.quaternary)
Capsule()
.fill(tint)
.frame(width: proxy.size.width * progress)
}
}
.frame(height: max(height, 0))
.padding(.horizontal, 12)
.accessibilityElement()
.accessibilityLabel("Reading progress")
.accessibilityValue(Double(progress).formatted(.percent.precision(.fractionLength(0))))
}
}
extension View {
func scrollProgressBar(
tint: LinearGradient = LinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing),
height: CGFloat = 5
) -> some View {
modifier(ScrollProgressBar(tint: tint, height: height))
}
}
// MARK: - Demo
struct ScrollProgressBarDemo: View {
@State private var position = ScrollPosition(y: 0)
@State private var maxOffset: CGFloat = 0
/// Only for this demo: it plays by itself until someone touches it.
@State private var isAutoPlaying = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let paragraph = "Good interfaces explain themselves. A small sign of how much is left can turn a long read into something that feels easy to finish, and it asks nothing of the reader."
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("The long read")
.font(.largeTitle.bold())
ForEach(0..<14, id: \.self) { index in
Text("Chapter \(index + 1)")
.font(.title2.bold())
Text(paragraph)
.font(.body)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
}
.scrollPosition($position)
.scrollProgressBar()
.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 autoScroll()
}
}
// Only the demo scrolls by itself: down for most of the loop, then quickly back up.
private func autoScroll() async {
var time = 0.0
while isAutoPlaying {
try? await Task.sleep(for: .milliseconds(16))
guard !Task.isCancelled, isAutoPlaying else { return }
time = (time + 0.016).truncatingRemainder(dividingBy: 3)
let fraction = time < 2.4 ? time / 2.4 : (3 - time) / 0.6
position.scrollTo(y: maxOffset * fraction)
}
}
}
#Preview {
ScrollProgressBarDemo()
}