Sticky Parallax Header
A parallax header that stretches on overscroll and collapses into a pinned bar.

- Scrolling
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- sticky header · parallax · stretchy · collapsing header · overscroll · scroll · hero · cabecera fija · paralaje · cabecera elástica · colapsar · desplazamiento · cabecera
Description
Section titled “Description”A header that stretches on overscroll, drifts at half speed and collapses into a pinned bar.
title receives how collapsed the header is, from 0 to 1. With Reduce Motion on, the backdrop does not drift.
struct StickyParallaxHeaderDemo: View { @ScaledMetric(relativeTo: .largeTitle) private var collapsedHeight = 60.0
private let trails = ["Tre Cime", "Seceda Ridge", "Lago di Braies", "Alpe di Siusi", "Sass Pordoi", "Cinque Torri", "Val di Funes", "Marmolada", "Passo Giau", "Lagazuoi"]
var body: some View { StickyParallaxHeader(collapsedHeight: collapsedHeight) { ZStack(alignment: .bottom) { LinearGradient(colors: [.indigo, .purple, .orange], startPoint: .top, endPoint: .bottom) Image(systemName: "mountain.2.fill") .resizable() .scaledToFit() .foregroundStyle(.black.opacity(0.35)) .padding(.horizontal, 24) .offset(y: 30) } } title: { collapse in VStack(alignment: .leading, spacing: 0) { Text("Dolomites") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .scaleEffect(1 - 0.3 * collapse, anchor: .bottomLeading) Text("10 trails nearby") .font(.subheadline) .opacity(1 - collapse * 2) .frame(height: collapse > 0.5 ? 0 : nil, alignment: .top) } .foregroundStyle(.white) } content: { LazyVStack(spacing: 12) { ForEach(trails, id: \.self) { trail in HStack(spacing: 14) { Image(systemName: "figure.hiking") .font(.title3) .foregroundStyle(.indigo) .accessibilityHidden(true) Text(trail) .font(.headline) Spacer() } .padding(18) .background(.indigo.opacity(0.1), in: .rect(cornerRadius: 18)) } } .padding(16) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
expandedHeight | CGFloat | 260 |
collapsedHeight | CGFloat | 60 |
backdrop | () -> Backdrop | Required |
title | (_ collapse: CGFloat) -> Title | Required |
content | () -> Content | 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
enum StickyParallaxHeaderMath { /// How far the backdrop drifts up: half the scroll, stopping before its bottom edge shows. static func drift(offset: CGFloat, fullHeight: CGFloat, height: CGFloat) -> CGFloat { min(max(offset, 0) * 0.5, max(fullHeight - height, 0)) }}
/// A header that stretches on overscroll, drifts at half speed and collapses into a pinned bar.////// `title` receives how collapsed the header is, from 0 to 1./// With Reduce Motion on, the backdrop does not drift.struct StickyParallaxHeader<Backdrop: View, Title: View, Content: View>: View { var expandedHeight: CGFloat = 260 var collapsedHeight: CGFloat = 60 @ViewBuilder var backdrop: () -> Backdrop @ViewBuilder var title: (_ collapse: CGFloat) -> Title @ViewBuilder var content: () -> Content
@State private var offset: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { GeometryReader { proxy in let fullHeight = expandedHeight + proxy.safeAreaInsets.top let pinnedHeight = collapsedHeight + proxy.safeAreaInsets.top let height = max(pinnedHeight, fullHeight - offset) let collapse = min(max(offset / max(fullHeight - pinnedHeight, 1), 0), 1)
ZStack(alignment: .top) { ScrollView { VStack(spacing: 0) { Color.clear.frame(height: fullHeight) content() } } .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.y + geometry.contentInsets.top } action: { _, newOffset in offset = newOffset }
header(height: height, fullHeight: fullHeight, collapse: collapse) } .ignoresSafeArea(edges: .top) } }
private func header(height: CGFloat, fullHeight: CGFloat, collapse: CGFloat) -> some View { backdrop() .frame(maxWidth: .infinity) .frame(height: max(fullHeight, height)) .offset(y: reduceMotion ? 0 : -StickyParallaxHeaderMath.drift(offset: offset, fullHeight: fullHeight, height: height)) .frame(height: height, alignment: .top) .clipped() .accessibilityHidden(true) .overlay(alignment: .bottomLeading) { title(collapse) .padding(.horizontal, 20) .padding(.bottom, 10) } .allowsHitTesting(false) }}
// MARK: - Demo
struct StickyParallaxHeaderDemo: View { @ScaledMetric(relativeTo: .largeTitle) private var collapsedHeight = 60.0
private let trails = ["Tre Cime", "Seceda Ridge", "Lago di Braies", "Alpe di Siusi", "Sass Pordoi", "Cinque Torri", "Val di Funes", "Marmolada", "Passo Giau", "Lagazuoi"]
var body: some View { StickyParallaxHeader(collapsedHeight: collapsedHeight) { ZStack(alignment: .bottom) { LinearGradient(colors: [.indigo, .purple, .orange], startPoint: .top, endPoint: .bottom) Image(systemName: "mountain.2.fill") .resizable() .scaledToFit() .foregroundStyle(.black.opacity(0.35)) .padding(.horizontal, 24) .offset(y: 30) } } title: { collapse in VStack(alignment: .leading, spacing: 0) { Text("Dolomites") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .scaleEffect(1 - 0.3 * collapse, anchor: .bottomLeading) Text("10 trails nearby") .font(.subheadline) .opacity(1 - collapse * 2) .frame(height: collapse > 0.5 ? 0 : nil, alignment: .top) } .foregroundStyle(.white) } content: { LazyVStack(spacing: 12) { ForEach(trails, id: \.self) { trail in HStack(spacing: 14) { Image(systemName: "figure.hiking") .font(.title3) .foregroundStyle(.indigo) .accessibilityHidden(true) Text(trail) .font(.headline) Spacer() } .padding(18) .background(.indigo.opacity(0.1), in: .rect(cornerRadius: 18)) } } .padding(16) } }}
#Preview { StickyParallaxHeaderDemo()}