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

- Scrolling
- iOS 18.0+
- beginner
- 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
Description
Section titled “Description”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) } }}API reference
Section titled “API reference”scrollProgressBar(tint:height:)
| Parameter | Type | Default |
|---|---|---|
tint | LinearGradient | LinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing) |
height | CGFloat | 5 |
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
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()}