Skip to content

Dissolve Shader Transition

A custom transition that burns a view away through noise, with a glowing edge, drawn by a Metal shader.

Dissolve Shader Transition, playing
Download .swiftDownload .metal
Origin
Original work
Found by
dissolve · shader · metal · noise · burn · disintegrate · transition · ember · threshold · color effect · disolver · disolución · shader · metal · ruido · quemar · desintegrar · transición · brasa · umbral

A transition that dissolves a view through noise, with a glowing edge where it burns away, drawn by a Metal shader. Use it as .transition(.dissolveShader).

A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.

With Reduce Motion on, the view fades instead.

struct DissolveShaderTransitionDemo: View {
@State private var isShown = true
var body: some View {
ZStack {
Color(red: 0.05, green: 0.04, blue: 0.08)
.ignoresSafeArea()
VStack(spacing: 36) {
// An empty space of the same shape keeps the button still while the poster is gone.
Color.clear
.aspectRatio(0.8, contentMode: .fit)
.overlay {
if isShown {
poster
.transition(.dissolveShader)
}
}
Button(isShown ? "Dissolve" : "Restore") {
withAnimation(.easeInOut(duration: 1.5)) { isShown.toggle() }
}
.font(.headline)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.orange)
.animation(nil, value: isShown)
}
.padding(.horizontal, 36)
}
}
private var poster: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(.largeTitle, weight: .semibold))
.imageScale(.large)
.accessibilityHidden(true)
Text("Ember")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Gone in a breath")
.font(.subheadline)
.opacity(0.8)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing),
in: .rect(cornerRadius: 28)
)
.accessibilityElement(children: .combine)
}
}
ParameterTypeDefaultDescription
edgeColor.orangeThe color of the edge that burns.
grainDouble44The size of the patches of noise, in points.

Copy both files into your project: the Swift file and the Metal shader it draws with. Or download DissolveShaderTransition.swift and DissolveShaderTransition.metal.

DissolveShaderTransition.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum DissolveShaderTransitionMath {
/// How wide the glowing edge is, as a share of the range of the noise.
static let edgeWidth = 0.12
/// The noise level under which a pixel is gone. It starts below every pixel and its glow,
/// so the view begins whole, and ends above them all.
static func threshold(progress: Double) -> Double {
let clamped = min(max(progress, 0), 1)
return clamped * (1 + edgeWidth) - edgeWidth
}
/// The function in `DissolveShaderTransition.metal`, with its arguments in the order it declares them.
static func shader(progress: Double, grain: Double, edge: Color) -> Shader {
ShaderLibrary.DissolveShaderTransitionBurn(
.float(threshold(progress: progress)),
.float(edgeWidth),
.float(max(grain, 1)),
.color(edge)
)
}
}
/// A transition that dissolves a view through noise, with a glowing edge where it burns away,
/// drawn by a Metal shader. Use it as `.transition(.dissolveShader)`.
///
/// - Important: Copy two files into the same target: `DissolveShaderTransition.swift` and `DissolveShaderTransition.metal`.
///
/// A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder.
///
/// With Reduce Motion on, the view fades instead.
struct DissolveShaderTransition: Transition {
/// The color of the edge that burns.
var edge: Color = .orange
/// The size of the patches of noise, in points.
var grain: Double = 44
func body(content: Content, phase: TransitionPhase) -> some View {
content.modifier(DissolveShaderTransitionEffect(progress: phase.isIdentity ? 0 : 1, edge: edge, grain: grain))
}
}
extension Transition where Self == DissolveShaderTransition {
static var dissolveShader: DissolveShaderTransition { DissolveShaderTransition() }
static func dissolveShader(edge: Color, grain: Double = 44) -> DissolveShaderTransition {
DissolveShaderTransition(edge: edge, grain: grain)
}
}
/// `Animatable`, so the shader gets every step of the transition: a shader's arguments do not animate by themselves.
private struct DissolveShaderTransitionEffect: ViewModifier, Animatable {
var progress: Double
let edge: Color
let grain: Double
@Environment(\.accessibilityReduceMotion) private var reduceMotion
nonisolated var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.colorEffect(
DissolveShaderTransitionMath.shader(progress: progress, grain: grain, edge: edge),
isEnabled: progress > 0 && !reduceMotion
)
.opacity(reduceMotion ? 1 - min(max(progress, 0), 1) : 1)
// The first use of a shader waits for it to compile. Doing that now keeps the first frames of the transition.
.task {
let shader = DissolveShaderTransitionMath.shader(progress: 0, grain: grain, edge: edge)
try? await shader.compile(as: .colorEffect)
}
}
}
// MARK: - Demo
struct DissolveShaderTransitionDemo: View {
@State private var isShown = true
var body: some View {
ZStack {
Color(red: 0.05, green: 0.04, blue: 0.08)
.ignoresSafeArea()
VStack(spacing: 36) {
// An empty space of the same shape keeps the button still while the poster is gone.
Color.clear
.aspectRatio(0.8, contentMode: .fit)
.overlay {
if isShown {
poster
.transition(.dissolveShader)
}
}
Button(isShown ? "Dissolve" : "Restore") {
withAnimation(.easeInOut(duration: 1.5)) { isShown.toggle() }
}
.font(.headline)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(.orange)
.animation(nil, value: isShown)
}
.padding(.horizontal, 36)
}
}
private var poster: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(.largeTitle, weight: .semibold))
.imageScale(.large)
.accessibilityHidden(true)
Text("Ember")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Gone in a breath")
.font(.subheadline)
.opacity(0.8)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing),
in: .rect(cornerRadius: 28)
)
.accessibilityElement(children: .combine)
}
}
#Preview {
DissolveShaderTransitionDemo()
}
DissolveShaderTransition.metal
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
// A repeatable pseudo-random number from 0 to 1 for each cell of a grid.
static float DissolveShaderTransitionRandom(float2 cell) {
uint2 seed = uint2(int2(cell));
uint mixed = (seed.x * 0x9E3779B1u) ^ (seed.y * 0x85EBCA77u);
mixed ^= mixed >> 15;
mixed *= 0x2C1B3C6Du;
mixed ^= mixed >> 12;
return float(mixed & 0xFFFFu) / 65535.0;
}
// Smooth noise from 0 to 1: a random value at each corner of a grid, blended
// across each cell.
static float DissolveShaderTransitionNoise(float2 point) {
float2 cell = floor(point);
float2 inside = point - cell;
float2 blend = inside * inside * (3.0 - 2.0 * inside);
float bottom = mix(
DissolveShaderTransitionRandom(cell),
DissolveShaderTransitionRandom(cell + float2(1.0, 0.0)),
blend.x
);
float top = mix(
DissolveShaderTransitionRandom(cell + float2(0.0, 1.0)),
DissolveShaderTransitionRandom(cell + float2(1.0, 1.0)),
blend.x
);
return mix(bottom, top, blend.y);
}
// For SwiftUI's `colorEffect`. Removes the pixels whose noise is under
// `threshold`, and makes the ones within `edgeWidth` above it glow in
// `edgeColor`, so the view burns away in patches of `grain` points.
[[ stitchable ]] half4 DissolveShaderTransitionBurn(
float2 position,
half4 color,
float threshold,
float edgeWidth,
float grain,
half4 edgeColor
) {
// Large patches, with a finer layer that roughens their outline.
float2 point = position / grain;
float noise = 0.7 * DissolveShaderTransitionNoise(point)
+ 0.3 * DissolveShaderTransitionNoise(point * 3.1 + 17.0);
// Noise gathers around the middle. Spreading it makes the burn last the
// whole transition.
noise = saturate((noise - 0.5) * 1.7 + 0.5);
// A short ramp keeps the cut from looking jagged.
float kept = smoothstep(threshold, threshold + 0.015, noise);
float heat = 1.0 - smoothstep(threshold, threshold + edgeWidth, noise);
// The edge is the color of the ember, and paler where it is hottest.
// Colors come premultiplied by their alpha.
half3 ember = mix(edgeColor.rgb, half3(1.0), half(0.35 * heat * heat)) * color.a;
half3 burning = mix(color.rgb, ember, half(heat));
return half4(burning, color.a) * half(kept);
}