Progressive Blur Header
A blur that grows toward the top edge of a scroll view, drawn by a Metal shader, so what scrolls under a header melts away.

- Effects
- iOS 18.0+
- advanced
- Metal shader
- Origin
- Original work
- Found by
- progressive blur · variable blur · header · shader · metal · scroll edge · layer effect · desenfoque progresivo · desenfoque variable · cabecera · shader · metal · borde de scroll · efecto de capa
Description
Section titled “Description”Blurs this view more and more toward the top edge of the scroll view it is in, drawn by a Metal shader, so what scrolls under a header melts away instead of meeting a hard bar. Put it on the content of a ScrollView, not on the scroll view.
A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map or the scroll view itself, comes out empty.
With Reduce Transparency on, the content is left sharp.
struct ProgressiveBlurHeaderDemo: View { private let entries: [(title: String, text: String, colors: [Color])] = [ ("Morning Light", "The harbor before the boats go out.", [.orange, .pink]), ("Slow Roads", "Three days with no highway in sight.", [.teal, .blue]), ("Market Day", "Peaches, noise and a very loud rooster.", [.yellow, .orange]), ("High Tide", "A beach that is only there at noon.", [.cyan, .indigo]), ("Old Stones", "A wall that outlived the town it kept.", [.gray, .brown]), ("Night Train", "Twelve hours, one window, no hurry.", [.indigo, .purple]), ]
var body: some View { ScrollView { LazyVStack(spacing: 16) { ForEach(entries, id: \.title) { entry in VStack(alignment: .leading, spacing: 6) { Spacer() Text(entry.title) .font(.title2.bold()) Text(entry.text) .font(.subheadline) } .foregroundStyle(.white) .padding(18) .frame(maxWidth: .infinity, minHeight: 170, alignment: .leading) .background(LinearGradient(colors: entry.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 22)) .accessibilityElement(children: .combine) } } .padding(.horizontal, 16) .padding(.top, 130) .progressiveBlurHeader() } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { HStack { Text("Journal") .font(.largeTitle.bold()) Spacer() Image(systemName: "square.and.pencil") .font(.title2) .accessibilityLabel("New entry") } .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 44) .background { // A veil over the blur keeps the title readable whatever scrolls under it. LinearGradient(colors: [Color(.systemBackground).opacity(0.9), Color(.systemBackground).opacity(0)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea(edges: .top) .allowsHitTesting(false) } } .background(Color(.systemBackground)) }}API reference
Section titled “API reference”progressiveBlurHeader(height:radius:)
| Parameter | Type | Default |
|---|---|---|
height | CGFloat | 130 |
radius | CGFloat | 14 |
Copy both files into your project: the Swift file and the Metal shader it draws with. Or download ProgressiveBlurHeader.swift and ProgressiveBlurHeader.metal.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum ProgressiveBlurHeaderMath { /// How much of the blur a point gets at a distance from the top edge: all of it at the edge /// and none from `height` down, along the same smooth curve the shader uses. static func strength(at distance: Double, height: Double) -> Double { guard height > 0, distance.isFinite else { return 0 } let share = min(max(distance / height, 0), 1) return 1 - share * share * (3 - 2 * share) }
/// The function in `ProgressiveBlurHeader.metal`, with its arguments in the order it declares them. static func shader(top: Double, height: Double, radius: Double, isHorizontal: Bool) -> Shader { ShaderLibrary.ProgressiveBlurHeaderPass( .float(top), .float(max(height, 0)), .float(max(radius, 0)), .float2(isHorizontal ? 1 : 0, isHorizontal ? 0 : 1) ) }}
extension View { /// Blurs this view more and more toward the top edge of the scroll view it is in, drawn by a /// Metal shader, so what scrolls under a header melts away instead of meeting a hard bar. /// Put it on the content of a `ScrollView`, not on the scroll view. /// /// - Important: Copy two files into the same target: `ProgressiveBlurHeader.swift` and `ProgressiveBlurHeader.metal`. /// /// A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map or the /// scroll view itself, comes out empty. /// /// With Reduce Transparency on, the content is left sharp. func progressiveBlurHeader(height: CGFloat = 130, radius: CGFloat = 14) -> some View { modifier(ProgressiveBlurHeaderModifier(height: height, radius: radius)) }}
private struct ProgressiveBlurHeaderModifier: ViewModifier { let height: CGFloat let radius: CGFloat
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
func body(content: Content) -> some View { content // `visualEffect` knows where the content is in the scroll view on every frame, with no state to update. .visualEffect { [height, radius, reduceTransparency] view, proxy in // The line of the content that sits at the top edge of the scroll view. let top = -proxy.frame(in: .scrollView(axis: .vertical)).minY
return view .layerEffect( ProgressiveBlurHeaderMath.shader(top: top, height: height, radius: radius, isHorizontal: true), maxSampleOffset: CGSize(width: radius, height: 0), isEnabled: !reduceTransparency ) .layerEffect( ProgressiveBlurHeaderMath.shader(top: top, height: height, radius: radius, isHorizontal: false), maxSampleOffset: CGSize(width: 0, height: radius), isEnabled: !reduceTransparency ) } // The first use of a shader waits for it to compile. Doing that now keeps the first frames smooth. .task { try? await ProgressiveBlurHeaderMath.shader(top: 0, height: height, radius: radius, isHorizontal: true).compile(as: .layerEffect) } }}
// MARK: - Demo
struct ProgressiveBlurHeaderDemo: View { private let entries: [(title: String, text: String, colors: [Color])] = [ ("Morning Light", "The harbor before the boats go out.", [.orange, .pink]), ("Slow Roads", "Three days with no highway in sight.", [.teal, .blue]), ("Market Day", "Peaches, noise and a very loud rooster.", [.yellow, .orange]), ("High Tide", "A beach that is only there at noon.", [.cyan, .indigo]), ("Old Stones", "A wall that outlived the town it kept.", [.gray, .brown]), ("Night Train", "Twelve hours, one window, no hurry.", [.indigo, .purple]), ]
var body: some View { ScrollView { LazyVStack(spacing: 16) { ForEach(entries, id: \.title) { entry in VStack(alignment: .leading, spacing: 6) { Spacer() Text(entry.title) .font(.title2.bold()) Text(entry.text) .font(.subheadline) } .foregroundStyle(.white) .padding(18) .frame(maxWidth: .infinity, minHeight: 170, alignment: .leading) .background(LinearGradient(colors: entry.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 22)) .accessibilityElement(children: .combine) } } .padding(.horizontal, 16) .padding(.top, 130) .progressiveBlurHeader() } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { HStack { Text("Journal") .font(.largeTitle.bold()) Spacer() Image(systemName: "square.and.pencil") .font(.title2) .accessibilityLabel("New entry") } .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 44) .background { // A veil over the blur keeps the title readable whatever scrolls under it. LinearGradient(colors: [Color(.systemBackground).opacity(0.9), Color(.systemBackground).opacity(0)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea(edges: .top) .allowsHitTesting(false) } } .background(Color(.systemBackground)) }}
#Preview { ProgressiveBlurHeaderDemo()}// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
#include <metal_stdlib>#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// For SwiftUI's `layerEffect`. Blurs along `direction` with a reach that is// `radius` at `top`, the line where the header starts, and falls smoothly to// nothing `height` points below it. One pass across and one pass down make// the whole blur, with far fewer samples than doing both at once.[[ stitchable ]] half4 ProgressiveBlurHeaderPass( float2 position, SwiftUI::Layer layer, float top, float height, float radius, float2 direction) { float reach = radius * (1.0 - smoothstep(0.0, height, position.y - top)); if (reach < 0.5) { return layer.sample(position); }
// Samples spread over the reach, weighted like a bell so the middle counts most. const int steps = 8; half4 total = half4(0.0); float weights = 0.0; for (int step = -steps; step <= steps; step++) { float offset = float(step) / float(steps); float weight = exp(-2.0 * offset * offset); total += layer.sample(position + direction * offset * reach) * half(weight); weights += weight; } return total / half(weights);}