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

- Transitions
- iOS 18.0+
- advanced
- Metal shader
- 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
Description
Section titled “Description”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) }}API reference
Section titled “API reference”| Parameter | Type | Default | Description |
|---|---|---|---|
edge | Color | .orange | The color of the edge that burns. |
grain | Double | 44 | The 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.
// 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()}// 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);}