Skip to content

Collapsing Bottom Bar

A bottom toolbar that shrinks to a pill while scrolling down and expands back when scrolling up.

Collapsing Bottom Bar, playing
Download .swift
Origin
Inspired by Kavsoft, written from scratch
Found by
bottom bar · toolbar · collapse · scroll direction · pill · shrink · barra inferior · barra de herramientas · contraer · dirección de desplazamiento · píldora · encoger

A bottom bar that shrinks to a pill while scrolling down and expands when scrolling up.

Apply it to a ScrollView with .collapsingBottomBar(items:). With Reduce Motion on, the bar changes size at once.

struct CollapsingBottomBarDemo: View {
@State private var lastAction = "Scroll to collapse the bar"
private var items: [CollapsingBottomBarItem] {
[
CollapsingBottomBarItem(id: "home", title: "Home", systemImage: "house.fill") { lastAction = "Home" },
CollapsingBottomBarItem(id: "search", title: "Search", systemImage: "magnifyingglass") { lastAction = "Search" },
CollapsingBottomBarItem(id: "add", title: "Add", systemImage: "plus.circle.fill") { lastAction = "Add" },
CollapsingBottomBarItem(id: "inbox", title: "Inbox", systemImage: "tray.fill") { lastAction = "Inbox" },
]
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
Text(lastAction)
.font(.title.bold())
ForEach(0..<20, id: \.self) { index in
RoundedRectangle(cornerRadius: 20)
.fill(LinearGradient(colors: [.orange, .pink], startPoint: .leading, endPoint: .trailing))
.hueRotation(.degrees(Double(index) * 18))
.frame(height: 110)
.accessibilityHidden(true)
}
}
.padding(20)
}
.collapsingBottomBar(items: items)
}
}

collapsingBottomBar(items:)

ParameterTypeDefault
items[CollapsingBottomBarItem]Required

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

CollapsingBottomBar.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct CollapsingBottomBarItem: Identifiable {
let id: String
let title: LocalizedStringResource
let systemImage: String
let action: () -> Void
}
/// Decides when the bar collapses. A change of direction must travel past a threshold,
/// so small movements of the finger do not make the bar flicker.
struct CollapsingBottomBarState: Equatable {
private(set) var isCollapsed = false
private var anchor: CGFloat = 0
mutating func update(offset: CGFloat, threshold: CGFloat) {
guard offset > 0 else {
isCollapsed = false
anchor = 0
return
}
let travel = offset - anchor
let limit = max(threshold, 0)
if isCollapsed ? travel < -limit : travel > limit {
isCollapsed.toggle()
anchor = offset
} else if isCollapsed ? travel > 0 : travel < 0 {
anchor = offset
}
}
/// Removes the rubber band at both ends, which would otherwise look like a change of direction.
static func clampedOffset(offsetY: CGFloat, topInset: CGFloat, bottomInset: CGFloat, contentHeight: CGFloat, containerHeight: CGFloat) -> CGFloat {
let maxOffset = max(contentHeight + topInset + bottomInset - containerHeight, 0)
return min(max(offsetY + topInset, 0), maxOffset)
}
}
/// A bottom bar that shrinks to a pill while scrolling down and expands when scrolling up.
///
/// Apply it to a `ScrollView` with `.collapsingBottomBar(items:)`. With Reduce Motion on,
/// the bar changes size at once.
struct CollapsingBottomBar: ViewModifier {
let items: [CollapsingBottomBarItem]
@State private var state = CollapsingBottomBarState()
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
content
.onScrollGeometryChange(for: CGFloat.self) { geometry in
CollapsingBottomBarState.clampedOffset(
offsetY: geometry.contentOffset.y,
topInset: geometry.contentInsets.top,
bottomInset: geometry.contentInsets.bottom,
contentHeight: geometry.contentSize.height,
containerHeight: geometry.containerSize.height
)
} action: { _, newValue in
state.update(offset: newValue, threshold: 24)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
CollapsingBottomBarSlot(items: items, isCollapsed: state.isCollapsed)
// The labels are short. The slot reads its height under this cap, which keeps every action on screen.
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
}
.animation(reduceMotion ? nil : .snappy(duration: 0.35), value: state.isCollapsed)
}
}
private struct CollapsingBottomBarSlot: View {
let items: [CollapsingBottomBarItem]
let isCollapsed: Bool
@ScaledMetric(relativeTo: .caption2) private var slotHeight = 86.0
var body: some View {
// A fixed slot keeps the inset stable, so the bar's own resizing cannot move the scroll offset.
bar.frame(height: slotHeight, alignment: .bottom)
}
private var bar: some View {
HStack(spacing: isCollapsed ? 6 : 0) {
ForEach(items) { item in
Button(action: item.action) {
VStack(spacing: 3) {
Image(systemName: item.systemImage)
.font(.title3)
if !isCollapsed {
Text(item.title)
.font(.caption2)
}
}
.frame(minWidth: 44, maxWidth: isCollapsed ? nil : .infinity, minHeight: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
}
}
.padding(.horizontal, isCollapsed ? 10 : 14)
.padding(.vertical, 8)
.background(.regularMaterial, in: .capsule)
.shadow(color: .black.opacity(0.15), radius: 12, y: 6)
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
}
extension View {
func collapsingBottomBar(items: [CollapsingBottomBarItem]) -> some View {
modifier(CollapsingBottomBar(items: items))
}
}
// MARK: - Demo
struct CollapsingBottomBarDemo: View {
@State private var lastAction = "Scroll to collapse the bar"
private var items: [CollapsingBottomBarItem] {
[
CollapsingBottomBarItem(id: "home", title: "Home", systemImage: "house.fill") { lastAction = "Home" },
CollapsingBottomBarItem(id: "search", title: "Search", systemImage: "magnifyingglass") { lastAction = "Search" },
CollapsingBottomBarItem(id: "add", title: "Add", systemImage: "plus.circle.fill") { lastAction = "Add" },
CollapsingBottomBarItem(id: "inbox", title: "Inbox", systemImage: "tray.fill") { lastAction = "Inbox" },
]
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
Text(lastAction)
.font(.title.bold())
ForEach(0..<20, id: \.self) { index in
RoundedRectangle(cornerRadius: 20)
.fill(LinearGradient(colors: [.orange, .pink], startPoint: .leading, endPoint: .trailing))
.hueRotation(.degrees(Double(index) * 18))
.frame(height: 110)
.accessibilityHidden(true)
}
}
.padding(20)
}
.collapsingBottomBar(items: items)
}
}
#Preview {
CollapsingBottomBarDemo()
}