Blur Up Image
An image that goes from a blurred preview into sharp focus once the full picture arrives.

- Images
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- blur up · progressive image · placeholder · async image · loading · lazy image · desenfoque · imagen progresiva · marcador de posición · imagen asíncrona · carga · enfocar
Description
Section titled “Description”Shows a blurred preview until the full image arrives, then brings it into focus.
It fills the space it is given. With Reduce Motion on, the full image fades in without the focus pull. Use BlurUpImageRemote to load the full image from a URL.
struct BlurUpImageDemo: View { @State private var isLoaded = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let preview = demoPhoto(size: CGSize(width: 18, height: 24)) private let full = demoPhoto(size: CGSize(width: 900, height: 1200))
var body: some View { VStack(spacing: 20) { BlurUpImage(preview: preview, image: isLoaded ? full : nil, label: "Mountains at dusk") .aspectRatio(3 / 4, contentMode: .fit) .clipShape(.rect(cornerRadius: 24))
Text(isLoaded ? "Full image loaded" : "Loading the full image") .font(.headline) .foregroundStyle(.secondary) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: reduceMotion) { guard !reduceMotion else { isLoaded = true return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1.3)) guard !Task.isCancelled else { return } isLoaded = true try? await Task.sleep(for: .seconds(2.3)) guard !Task.isCancelled else { return } isLoaded = false } } }}
private func demoPhoto(size: CGSize) -> Image { Image(size: size, label: Text("Mountains at dusk")) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient( Gradient(colors: [.indigo, .purple, .orange]), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height) ) ) let sun = size.width * 0.22 context.fill( Path(ellipseIn: CGRect(x: size.width * 0.6, y: size.height * 0.18, width: sun, height: sun)), with: .color(.white.opacity(0.85)) )
var ridge = Path() ridge.move(to: CGPoint(x: 0, y: size.height)) ridge.addLine(to: CGPoint(x: 0, y: size.height * 0.63)) ridge.addCurve( to: CGPoint(x: size.width, y: size.height * 0.55), control1: CGPoint(x: size.width / 3, y: size.height * 0.4), control2: CGPoint(x: size.width * 0.66, y: size.height * 0.85) ) ridge.addLine(to: CGPoint(x: size.width, y: size.height)) ridge.closeSubpath() context.fill(ridge, with: .color(Color(red: 0.12, green: 0.12, blue: 0.32))) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
preview | Image | Required |
image | Image? | Required |
label | LocalizedStringResource | Required |
blurRadius | CGFloat | 24 |
isLoading | Bool | true |
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
private struct BlurUpImageFocus: Transition { let radius: CGFloat
func body(content: Content, phase: TransitionPhase) -> some View { content .blur(radius: phase.isIdentity ? 0 : radius) .opacity(phase.isIdentity ? 1 : 0) }}
/// Shows a blurred preview until the full image arrives, then brings it into focus.////// It fills the space it is given. With Reduce Motion on, the full image fades in without the/// focus pull. Use `BlurUpImageRemote` to load the full image from a URL.struct BlurUpImage: View { let preview: Image let image: Image? let label: LocalizedStringResource var blurRadius: CGFloat = 24 var isLoading = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var radius: CGFloat { max(blurRadius, 0) }
private var transition: AnyTransition { reduceMotion ? .opacity : AnyTransition(BlurUpImageFocus(radius: radius)) }
var body: some View { Color.clear .overlay { preview .resizable() .scaledToFill() .blur(radius: radius, opaque: true) } .overlay { if let image { image .resizable() .scaledToFill() .transition(transition) } } .overlay { if image == nil, isLoading { ProgressView() .tint(.white) } } .clipped() .animation(.easeOut(duration: reduceMotion ? 0.2 : 0.8), value: image != nil) .accessibilityElement(children: .ignore) .accessibilityLabel(label) .accessibilityValue(image == nil && isLoading ? "Loading" : "") .accessibilityAddTraits(.isImage) }}
/// Loads the full image from a URL. The preview stays on screen if the download fails.struct BlurUpImageRemote: View { let url: URL? let preview: Image let label: LocalizedStringResource
var body: some View { AsyncImage(url: url) { phase in BlurUpImage(preview: preview, image: phase.image, label: label, isLoading: url != nil && phase.error == nil) } }}
// MARK: - Demo
struct BlurUpImageDemo: View { @State private var isLoaded = false @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let preview = demoPhoto(size: CGSize(width: 18, height: 24)) private let full = demoPhoto(size: CGSize(width: 900, height: 1200))
var body: some View { VStack(spacing: 20) { BlurUpImage(preview: preview, image: isLoaded ? full : nil, label: "Mountains at dusk") .aspectRatio(3 / 4, contentMode: .fit) .clipShape(.rect(cornerRadius: 24))
Text(isLoaded ? "Full image loaded" : "Loading the full image") .font(.headline) .foregroundStyle(.secondary) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: reduceMotion) { guard !reduceMotion else { isLoaded = true return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1.3)) guard !Task.isCancelled else { return } isLoaded = true try? await Task.sleep(for: .seconds(2.3)) guard !Task.isCancelled else { return } isLoaded = false } } }}
private func demoPhoto(size: CGSize) -> Image { Image(size: size, label: Text("Mountains at dusk")) { context in context.fill( Path(CGRect(origin: .zero, size: size)), with: .linearGradient( Gradient(colors: [.indigo, .purple, .orange]), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height) ) ) let sun = size.width * 0.22 context.fill( Path(ellipseIn: CGRect(x: size.width * 0.6, y: size.height * 0.18, width: sun, height: sun)), with: .color(.white.opacity(0.85)) )
var ridge = Path() ridge.move(to: CGPoint(x: 0, y: size.height)) ridge.addLine(to: CGPoint(x: 0, y: size.height * 0.63)) ridge.addCurve( to: CGPoint(x: size.width, y: size.height * 0.55), control1: CGPoint(x: size.width / 3, y: size.height * 0.4), control2: CGPoint(x: size.width * 0.66, y: size.height * 0.85) ) ridge.addLine(to: CGPoint(x: size.width, y: size.height)) ridge.closeSubpath() context.fill(ridge, with: .color(Color(red: 0.12, green: 0.12, blue: 0.32))) }}
#Preview { BlurUpImageDemo()}