Hide On Scroll Bar
A bottom bar that slides out of view while the user scrolls down and returns as soon as they scroll up.

- Scrolling
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- hide on scroll · bottom bar · tab bar · scroll direction · toolbar · auto hide · ocultar al desplazar · barra inferior · barra de pestañas · dirección de desplazamiento · barra de herramientas · ocultación automática
Description
Section titled “Description”A scroll view with a bottom bar that hides while scrolling down and returns on scrolling up.
With Reduce Motion on, the bar fades instead of sliding. With VoiceOver on, it stays visible.
struct HideOnScrollBarDemo: View { /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @State private var selected = "Home" @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let colors: [Color] = [.indigo, .teal, .orange, .pink, .blue, .green]
var body: some View { ScrollViewReader { proxy in HideOnScrollBar { VStack(spacing: 12) { ForEach(0..<30, id: \.self) { index in row(index) .id(index) } } .padding(16) } bar: { HStack(spacing: 0) { barButton("Home", symbol: "house.fill") barButton("Search", symbol: "magnifyingglass") barButton("Library", symbol: "books.vertical.fill") barButton("Profile", symbol: "person.fill") } .padding(.vertical, 6) .padding(.horizontal, 8) .background(.regularMaterial, in: .capsule) .shadow(color: .black.opacity(0.18), radius: 14, y: 6) .padding(.horizontal, 28) .padding(.bottom, 4) } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } var target = 0 while isAutoPlaying { try? await Task.sleep(for: .seconds(1.6)) guard !Task.isCancelled, isAutoPlaying else { return } target = target == 12 ? 4 : 12 withAnimation(.easeInOut(duration: 1)) { proxy.scrollTo(target, anchor: .top) } } } } }
private func row(_ index: Int) -> some View { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(colors[index % colors.count].gradient) .frame(width: 56, height: 56) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text("Article \(index + 1)") .font(.headline) Text("A short line of supporting text.") .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(12) .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 18, style: .continuous)) }
private func barButton(_ title: String, symbol: String) -> some View { Button { selected = title } label: { Image(systemName: symbol) .font(.title3) .frame(maxWidth: .infinity, minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) .foregroundStyle(selected == title ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .accessibilityLabel(title) .accessibilityAddTraits(selected == title ? .isSelected : []) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
threshold | Double | 16 |
content | () -> Content | Required |
bar | () -> Bar | Required |
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
/// A scroll view with a bottom bar that hides while scrolling down and returns on scrolling up.////// With Reduce Motion on, the bar fades instead of sliding. With VoiceOver on, it stays visible.struct HideOnScrollBar<Content: View, Bar: View>: View { var threshold: Double = 16 @ViewBuilder var content: () -> Content @ViewBuilder var bar: () -> Bar
@State private var tracker = HideOnScrollBarTracker() @State private var isHidden = false @State private var barHeight: CGFloat = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
private var hidesBar: Bool { isHidden && !voiceOverEnabled }
var body: some View { ScrollView(content: content) .safeAreaInset(edge: .bottom, spacing: 0) { bar() .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { barHeight = $0 } .offset(y: hidesBar && !reduceMotion ? barHeight + 60 : 0) .opacity(hidesBar && reduceMotion ? 0 : 1) .allowsHitTesting(!hidesBar) .animation(reduceMotion ? .linear(duration: 0.15) : .snappy(duration: 0.3), value: hidesBar) } .onScrollGeometryChange(for: HideOnScrollBarMetrics.self) { geometry in HideOnScrollBarMetrics( offset: geometry.contentOffset.y + geometry.contentInsets.top, maxOffset: geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom - geometry.containerSize.height ) } action: { _, metrics in tracker.update(offset: metrics.offset, maxOffset: metrics.maxOffset, threshold: threshold) isHidden = tracker.isHidden } }}
private struct HideOnScrollBarMetrics: Equatable { let offset: Double let maxOffset: Double}
/// Decides when the bar hides, ignoring small movements and the bounce at both ends.struct HideOnScrollBarTracker: Equatable { private(set) var isHidden = false private var lastOffset = 0.0 private var travel = 0.0
mutating func update(offset: Double, maxOffset: Double, threshold: Double = 16) { guard offset > 0, maxOffset > 0 else { isHidden = false lastOffset = max(offset, 0) travel = 0 return } guard offset < maxOffset else { lastOffset = maxOffset return }
let delta = offset - lastOffset lastOffset = offset guard delta != 0 else { return }
if (delta > 0) != (travel > 0) { travel = 0 } travel += delta
let required = max(threshold, 0) if travel >= required { isHidden = true } else if travel <= -required { isHidden = false } }}
// MARK: - Demo
struct HideOnScrollBarDemo: View { /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @State private var selected = "Home" @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let colors: [Color] = [.indigo, .teal, .orange, .pink, .blue, .green]
var body: some View { ScrollViewReader { proxy in HideOnScrollBar { VStack(spacing: 12) { ForEach(0..<30, id: \.self) { index in row(index) .id(index) } } .padding(16) } bar: { HStack(spacing: 0) { barButton("Home", symbol: "house.fill") barButton("Search", symbol: "magnifyingglass") barButton("Library", symbol: "books.vertical.fill") barButton("Profile", symbol: "person.fill") } .padding(.vertical, 6) .padding(.horizontal, 8) .background(.regularMaterial, in: .capsule) .shadow(color: .black.opacity(0.18), radius: 14, y: 6) .padding(.horizontal, 28) .padding(.bottom, 4) } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task(id: reduceMotion) { guard !reduceMotion else { return } var target = 0 while isAutoPlaying { try? await Task.sleep(for: .seconds(1.6)) guard !Task.isCancelled, isAutoPlaying else { return } target = target == 12 ? 4 : 12 withAnimation(.easeInOut(duration: 1)) { proxy.scrollTo(target, anchor: .top) } } } } }
private func row(_ index: Int) -> some View { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(colors[index % colors.count].gradient) .frame(width: 56, height: 56) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text("Article \(index + 1)") .font(.headline) Text("A short line of supporting text.") .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(12) .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 18, style: .continuous)) }
private func barButton(_ title: String, symbol: String) -> some View { Button { selected = title } label: { Image(systemName: symbol) .font(.title3) .frame(maxWidth: .infinity, minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) .foregroundStyle(selected == title ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .accessibilityLabel(title) .accessibilityAddTraits(selected == title ? .isSelected : []) }}
#Preview { HideOnScrollBarDemo()}