How to Implement a Sticky Header in SwiftUI
Wrap your rows in a Section inside
LazyVStack(pinnedViews: [.sectionHeaders]) —
SwiftUI pins the section's header: view to the top automatically as the user scrolls.
Add .background(.regularMaterial) to give the header the native frosted-glass look.
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(1...50, id: \.self) { i in
Text("Row \(i)").padding()
Divider()
}
} header: {
Text("Sticky Header")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
}
}
}
Full implementation
The component below layers two ideas: a GeometryReader-powered hero
that fills the status-bar area and rubber-bands on overscroll, and a
LazyVStack that pins a filter bar to the top once the hero leaves the viewport.
Reading proxy.frame(in: .global).minY gives the hero's distance from the
screen edge every frame, which drives both the stretch and the offset in one expression.
VoiceOver labels are combined at the row level so the screen reader doesn't land on each
sub-view individually.
import SwiftUI
struct StickyHeaderView: View {
private let heroHeight: CGFloat = 240
private let items: [String] = (1...40).map { "Item \($0)" }
var body: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
heroSection
Section {
ForEach(items, id: \.self) { item in
itemRow(item)
}
} header: {
stickyBar
}
}
}
.ignoresSafeArea(edges: .top)
}
// MARK: – Hero (scrolls away, stretches on overscroll)
private var heroSection: some View {
GeometryReader { proxy in
let minY = proxy.frame(in: .global).minY
ZStack(alignment: .bottomLeading) {
LinearGradient(
colors: [.indigo, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(height: max(heroHeight, heroHeight + minY))
.offset(y: min(0, -minY))
VStack(alignment: .leading, spacing: 4) {
Text("Catalog")
.font(.largeTitle.bold())
.foregroundStyle(.white)
Text("\(items.count) items available")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.frame(height: heroHeight)
}
// MARK: – Sticky bar (pinned to top via pinnedViews)
private var stickyBar: some View {
HStack {
Label("All Items", systemImage: "square.grid.2x2")
.font(.headline)
Spacer()
Button {
// present filter sheet
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.imageScale(.large)
}
}
.padding(.horizontal)
.frame(height: 52)
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
}
// MARK: – Row
private func itemRow(_ name: String) -> some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10)
.fill(.indigo.opacity(0.12))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "cube.box")
.foregroundStyle(.indigo)
}
VStack(alignment: .leading, spacing: 2) {
Text(name).font(.headline)
Text("Tap to view details")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(name). Tap to view details.")
Divider().padding(.leading, 72)
}
}
}
#Preview {
StickyHeaderView()
}
How it works
-
pinnedViews: [.sectionHeaders] — This single argument on
LazyVStackactivates SwiftUI's built-in pinning engine. When a section's rows start scrolling past the top of the scroll container, theheader:view is lifted out of the normal flow and held at the top edge — no manual offset calculations needed. -
GeometryReader + global coordinate space — Calling
proxy.frame(in: .global).minYinside the hero returns how far the top edge of the hero is from the top of the screen. On overscrollminYbecomes positive, somax(heroHeight, heroHeight + minY)grows the gradient frame to fill the rubber-banded gap whilemin(0, -minY)pins it to the top edge. - .ignoresSafeArea(edges: .top) on the ScrollView — This makes the scroll content extend beneath the status bar, giving the hero a true full-bleed appearance. The sticky bar's material background automatically handles legibility above any system-drawn status-bar content.
-
.background(.regularMaterial) — Applies the system frosted-glass effect to the sticky bar.
As rows scroll behind it, they blur rather than collide visually with the header text.
The
Divider()overlay on the bottom edge creates a hairline separator that adapts to light and dark mode without a hardcoded color. -
accessibilityElement(children: .combine) — Merges the thumbnail, title, and
subtitle of each row into one VoiceOver stop. Without it, VoiceOver would read the
Image,Text, and chevron as separate focusable elements, creating a noisy, slow navigation experience.
Variants
Multiple pinned section headers
Add more Section blocks inside the same
LazyVStack.
Each section header pins independently — the incoming header pushes the outgoing one off-screen
as you scroll, giving the classic iOS grouped-list behaviour.
struct MultiSectionView: View {
let categories = ["Fruits", "Vegetables", "Grains"]
var body: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(categories, id: \.self) { category in
Section {
ForEach(1...8, id: \.self) { i in
VStack(spacing: 0) {
Text("\(category) item \(i)")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Divider()
}
}
} header: {
Text(category)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.vertical, 10)
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
}
}
}
}
}
}
#Preview { MultiSectionView() }
Collapsing / shrinking sticky bar on scroll
To shrink the bar as the hero disappears, place a zero-height
GeometryReader at the very top of the scroll content,
read its minY in the scroll's
.coordinateSpace(name: "scroll"),
and drive a @State var barHeight
clamped between a compact and an expanded value.
Wrap state mutations in withAnimation(.easeOut(duration: 0.15))
to avoid jank. On iOS 18+ prefer onScrollGeometryChange
for a cleaner, main-actor-aware API that avoids the
PreferenceKey boilerplate entirely.
Common pitfalls
-
Using VStack instead of LazyVStack — A plain
VStacksilently ignores thepinnedViewsparameter; the header simply scrolls away with no warning. You must useLazyVStack. -
Unconstrained GeometryReader height —
GeometryReadergreedily expands to fill all offered space. Always cap it at the call site with.frame(height: heroHeight); otherwise it will consume the entire scroll area and your rows will never appear. -
Missing .ignoresSafeArea on the ScrollView — If you want the hero to bleed under
the status bar but apply
.ignoresSafeAreato an inner view instead of theScrollViewitself, you'll see a white gap. Move the modifier to the outermostScrollView. -
Expensive work inside onPreferenceChange — Scroll-offset preference changes fire
on every rendered frame (up to 120 Hz on ProMotion). Avoid triggering layout-heavy view
recomputations; update only a single
@Statescalar and derive everything else from it.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a sticky header in SwiftUI for iOS 17+. Use ScrollView and LazyVStack(pinnedViews: [.sectionHeaders]). Add a GeometryReader-based parallax hero above the sticky bar. Apply .background(.regularMaterial) to the pinned header. Make it accessible (accessibilityElement + VoiceOver labels). Add a #Preview with realistic sample data (at least 30 rows).
In Soarias's Build phase, paste this prompt into the Claude Code panel right after your screen list is scaffolded — it generates the sticky header component and slots it into your existing navigation hierarchy without touching unrelated screens.
Related
FAQ
Does this work on iOS 16?
LazyVStack(pinnedViews:) is available from iOS 14+, so the pinning
technique works on iOS 16. However, .background(.regularMaterial)
requires iOS 15+, and the #Preview macro requires Xcode 15+.
For iOS 16 targets simply replace the macro with a PreviewProvider;
no other changes are needed.
Can I animate the sticky bar as it collapses?
Yes. Track scroll offset via a PreferenceKey on iOS 17, or the cleaner
onScrollGeometryChange on iOS 18+.
Clamp the raw offset to a barHeight range
(e.g. 52 → 36 pt) and drive font size or padding from it.
Wrap updates in withAnimation(.interactiveSpring(response: 0.3))
for a smooth, physics-driven feel. Keep state changes minimal — on a 120 Hz ProMotion display
every unnecessary layout pass costs precious frame budget.
What's the UIKit equivalent?
In UIKit, UITableView.Style.plain pins section headers by default.
For collection views you use UICollectionViewCompositionalLayout
with a supplementary boundary item pinned to
.topAttached and
extendsBoundary: true.
SwiftUI's pinnedViews: [.sectionHeaders] delivers the same result
in a handful of lines with no delegate methods or layout objects required.
Last reviewed: 2026-05-12 by the Soarias team.