How to Implement a Sticky Header in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: ScrollView / GeometryReader Updated: May 12, 2026
TL;DR

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

  1. pinnedViews: [.sectionHeaders] — This single argument on LazyVStack activates SwiftUI's built-in pinning engine. When a section's rows start scrolling past the top of the scroll container, the header: view is lifted out of the normal flow and held at the top edge — no manual offset calculations needed.
  2. GeometryReader + global coordinate space — Calling proxy.frame(in: .global).minY inside the hero returns how far the top edge of the hero is from the top of the screen. On overscroll minY becomes positive, so max(heroHeight, heroHeight + minY) grows the gradient frame to fill the rubber-banded gap while min(0, -minY) pins it to the top edge.
  3. .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.
  4. .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.
  5. 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

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.