Skip to content

Sticky Parallax Header

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

Sticky Parallax Header, playing
Download .swift
Origin
Original work
Found by
sticky header · parallax · stretchy · collapsing header · overscroll · scroll · hero · cabecera fija · paralaje · cabecera elástica · colapsar · desplazamiento · cabecera

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)
}
}
}
ParameterTypeDefault
expandedHeightCGFloat260
collapsedHeightCGFloat60
backdrop() -> BackdropRequired
title(_ collapse: CGFloat) -> TitleRequired
content() -> ContentRequired

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

StickyParallaxHeader.swift
// 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()
}