Skip to content

Blur Up Image

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

Blur Up Image, playing
Download .swift
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

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)))
}
}
ParameterTypeDefault
previewImageRequired
imageImage?Required
labelLocalizedStringResourceRequired
blurRadiusCGFloat24
isLoadingBooltrue

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

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