Kajsa Lindqvist
Kajsa LindqvistNov 4, 2025

SwiftUI Metal Shaders: Creating Custom Visual Effects

Master Metal shaders in SwiftUI to create stunning custom visual effects. Learn shader fundamentals, build distortion effects, color filters, and performance-optimized GPU-accelerated visuals.
SwiftUI Metal Shaders

Metal shaders unlock GPU-accelerated visual effects in SwiftUI, enabling you to create stunning custom graphics that would be impossible with standard SwiftUI views. From subtle color filters to mind-bending distortions, shaders give you pixel-level control over your UI while maintaining buttery-smooth 60fps performance.

In this comprehensive guide, you'll learn how to harness the power of Metal shaders to create professional-grade visual effects in your SwiftUI apps. By the end, you'll be crafting custom shaders that make your apps stand out.

What Are Metal Shaders?

Shaders are small programs that run directly on your device's GPU. Unlike CPU-based operations, shaders process every pixel in parallel, making them incredibly fast for visual effects.

Why Use Shaders in SwiftUI?

  • Performance: GPU processing is orders of magnitude faster for graphics
  • Creative freedom: Pixel-level control enables effects impossible with standard views
  • Smooth animations: 60fps+ even with complex visual processing
  • Battery efficiency: GPUs are optimized for this work, using less power than CPU alternatives

SwiftUI's shader support (introduced in iOS 17 and macOS 14) makes Metal shaders accessible through simple, declarative APIs.


Understanding Shader Basics

Before writing code, let's understand the fundamentals.

Shader Types in SwiftUI

SwiftUI supports three shader types:

  1. Color Shaders: Modify the color of each pixel
  2. Distortion Shaders: Move pixels to different positions
  3. Layer Shaders: Access and combine multiple layers

Metal Shading Language (MSL)

Shaders are written in Metal Shading Language, which looks similar to C++:

[[ stitchable ]] half4 simpleShader(float2 position) {
    return half4(1.0, 0.0, 0.0, 1.0); // Red color
}

Key concepts:

  • [[stitchable]]: Required attribute for SwiftUI shaders
  • half4: 4-component color (RGBA) using 16-bit floats
  • float2: 2D position vector
  • Return value: The output color for that pixel

Setting Up Your Project

Step 1: Create a Metal File

In Xcode:

  1. Right-click your project
  2. Select New File > Metal File
  3. Name it Shaders.metal

Step 2: Basic Metal Shader Template

Shaders.metal
#include <metal_stdlib>
using namespace metal;
 
[[ stitchable ]] half4 exampleShader(float2 position) {
    // Your shader code here
    return half4(1.0); // White
}

Step 3: Using Shaders in SwiftUI

ContentView.swift
import SwiftUI
 
struct ContentView: View {
    var body: some View {
        Rectangle()
            .frame(width: 300, height: 300)
            .colorEffect(ShaderLibrary.exampleShader())
    }
}

Part 1: Color Shader Effects

Color shaders are the simplest type and perfect for learning. They modify pixel colors without moving them.

Creating a Gradient Shader

Gradient shader effect
Shaders.metal
[[ stitchable ]] half4 gradientShader(
    float2 position,
    half4 currentColor,
    float4 bounds
) {
    // Extract size from bounds (width, height)
    float2 size = bounds.zw;
 
    // Normalize position (0 to 1)
    float2 uv = position / size;
 
    // Create horizontal gradient from blue to purple
    half3 color1 = half3(0.2, 0.4, 1.0); // Blue
    half3 color2 = half3(0.8, 0.2, 1.0); // Purple
 
    // Mix colors based on horizontal position
    half t = half(clamp(uv.x, 0.0, 1.0));
    half3 rgb = mix(color1, color2, t);
 
    // Preserve original alpha for anti-aliasing
    return half4(rgb, currentColor.a);
}

Using it in SwiftUI:

Rectangle()
    .frame(width: 300, height: 300)
    .colorEffect(
        ShaderLibrary.gradientShader(
            .boundingRect
        )
    )

Animated Hue Shift

Create a continuously shifting rainbow effect:

Animated hue shift effect
Shaders.metal
[[ stitchable ]] half4 hueShift(
    float2 position,
    half4 currentColor,
    float time
) {
    // Convert to HSV
    float3 rgb = float3(currentColor.rgb);
    float maxC = max(max(rgb.r, rgb.g), rgb.b);
    float minC = min(min(rgb.r, rgb.g), rgb.b);
    float delta = maxC - minC;
 
    float hue = 0.0;
    if (delta != 0.0) {
        if (maxC == rgb.r) {
            hue = (rgb.g - rgb.b) / delta;
        } else if (maxC == rgb.g) {
            hue = 2.0 + (rgb.b - rgb.r) / delta;
        } else {
            hue = 4.0 + (rgb.r - rgb.g) / delta;
        }
        hue = hue / 6.0;
        if (hue < 0.0) hue += 1.0;
    }
 
    // Shift hue over time
    hue = fmod(hue + time, 1.0);
 
    // Convert back to RGB
    float saturation = delta / maxC;
    float value = maxC;
 
    float c = value * saturation;
    float x = c * (1.0 - abs(fmod(hue * 6.0, 2.0) - 1.0));
    float m = value - c;
 
    float3 rgbPrime;
    if (hue < 1.0/6.0) rgbPrime = float3(c, x, 0);
    else if (hue < 2.0/6.0) rgbPrime = float3(x, c, 0);
    else if (hue < 3.0/6.0) rgbPrime = float3(0, c, x);
    else if (hue < 4.0/6.0) rgbPrime = float3(0, x, c);
    else if (hue < 5.0/6.0) rgbPrime = float3(x, 0, c);
    else rgbPrime = float3(c, 0, x);
 
    float3 finalRGB = rgbPrime + m;
 
    return half4(half3(finalRGB), currentColor.a);
}

Animate it:

struct HueShiftView: View {
    @State private var time: Float = 0
 
    var body: some View {
        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .colorEffect(
                ShaderLibrary.hueShift(
                    .float(time)
                )
            )
            .onAppear {
                withAnimation(.linear(duration: 10).repeatForever(autoreverses: false)) {
                    time = 1.0
                }
            }
    }
}

Part 2: Distortion Shaders

Distortion shaders move pixels to create warping, rippling, and morphing effects.

Wave Distortion Effect

Create a wavy, water-like distortion:

Wave distortion effect
Shaders.metal
[[ stitchable ]] float2 waveDistortion(
    float2 position,
    float time,
    float amplitude,
    float frequency
) {
    // Create wave effect
    float wave = sin(position.y * frequency + time) * amplitude;
 
    // Offset x position
    float2 newPosition = position;
    newPosition.x += wave;
 
    return newPosition;
}

Apply to an image:

struct WaveView: View {
    @State private var time: Float = 0
 
    var body: some View {
        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .clipShape(RoundedRectangle(cornerRadius: 20))
            .distortionEffect(
                ShaderLibrary.waveDistortion(
                    .float(time),
                    .float(15), // amplitude
                    .float(0.05) // frequency
                ),
                maxSampleOffset: CGSize(width: 20, height: 0)
            )
            .onAppear {
                withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
                    time = Float.pi * 2
                }
            }
    }
}

Ripple Effect

Create expanding ripples like water drops:

Shaders.metal
[[ stitchable ]] float2 rippleEffect(
    float2 position,
    float2 center,
    float time,
    float amplitude,
    float4 bounds
) {
    // Extract size from bounds
    float2 size = bounds.zw;
 
    // Distance from center
    float2 toCenter = position - center * size;
    float distance = length(toCenter);
 
    // Create ripple
    float ripple = sin(distance * 0.1 - time * 5.0) * amplitude;
    ripple *= smoothstep(0.0, 100.0, distance); // Fade out at center
    ripple *= smoothstep(500.0, 300.0, distance); // Fade out at edges
 
    // Offset position
    float2 direction = normalize(toCenter);
    return position + direction * ripple;
}

Interactive ripples:

struct RippleView: View {
    @State private var time: Float = 0
    @State private var tapLocation: CGPoint = .zero
 
    var body: some View {
        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: 400, height: 400)
            .distortionEffect(
                ShaderLibrary.rippleEffect(
                    .float2(tapLocation),
                    .float(time),
                    .float(20),
                    .boundingRect
                ),
                maxSampleOffset: CGSize(width: 30, height: 30)
            )
            .onTapGesture { location in
                tapLocation = location
                time = 0
                withAnimation(.linear(duration: 2)) {
                    time = 10
                }
            }
    }
}

Part 3: Advanced Shader Techniques

Combining Multiple Effects

Layer multiple shaders for complex visuals:

Combined wave, hue shift, and blur effects
Image("photo")
    .resizable()
    .frame(width: 300, height: 300)
    .distortionEffect(ShaderLibrary.waveDistortion(...))
    .colorEffect(ShaderLibrary.hueShift(...))
    .blur(radius: 2)

Noise Functions

Perlin noise adds organic, random variation:

Shaders.metal
// Simplified Perlin noise
float hash(float2 p) {
    return fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
 
float noise(float2 p) {
    float2 i = floor(p);
    float2 f = fract(p);
 
    float a = hash(i);
    float b = hash(i + float2(1.0, 0.0));
    float c = hash(i + float2(0.0, 1.0));
    float d = hash(i + float2(1.0, 1.0));
 
    float2 u = f * f * (3.0 - 2.0 * f);
 
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
 
[[ stitchable ]] half4 noiseEffect(
    float2 position,
    half4 currentColor,
    float time
) {
    float n = noise(position * 0.01 + time);
    return currentColor * half(n);
}

Bloom Effect

Create a glowing, luminous effect:

Shaders.metal
[[ stitchable ]] half4 bloomEffect(
    float2 position,
    half4 currentColor,
    float intensity
) {
    // Extract bright areas
    float brightness = dot(currentColor.rgb, half3(0.299, 0.587, 0.114));
 
    if (brightness > 0.7) {
        // Amplify bright pixels
        half3 bloom = currentColor.rgb * half(intensity);
        return half4(bloom, currentColor.a);
    }
 
    return currentColor;
}

Part 4: Performance Optimization

Best Practices

  1. Minimize calculations: Pre-compute values when possible
  2. Use appropriate precision: half for colors, float for positions
  3. Avoid branches: GPU processes work best without if/else
  4. Cache uniform values: Pass constants as parameters

Optimized Shader Example

// ❌ Inefficient
[[ stitchable ]] half4 inefficientShader(float2 position) {
    if (position.x > 100.0) {
        return half4(1.0, 0.0, 0.0, 1.0);
    } else {
        return half4(0.0, 1.0, 0.0, 1.0);
    }
}
 
// ✅ Efficient
[[ stitchable ]] half4 efficientShader(float2 position) {
    half4 color1 = half4(1.0, 0.0, 0.0, 1.0);
    half4 color2 = half4(0.0, 1.0, 0.0, 1.0);
    float factor = smoothstep(90.0, 110.0, position.x);
    return mix(color1, color2, half(factor));
}

Measuring Performance

import os
 
struct ShaderPerformanceView: View {
    let signposter = OSSignposter()
 
    var body: some View {
        Image("photo")
            .resizable()
            .frame(width: 400, height: 400)
            .colorEffect(ShaderLibrary.complexShader(...))
            .onAppear {
                let state = signposter.beginInterval("Shader Render")
                // Shader applies here
                signposter.endInterval("Shader Render", state)
            }
    }
}

Check Instruments > Time Profiler for GPU usage.


Part 5: Real-World Applications

Creating a Glass Morphism Effect

Modern, frosted glass UI:

Shaders.metal
[[ stitchable ]] half4 glassMorphism(
    float2 position,
    half4 currentColor,
    float4 bounds,
    float blur,
    float opacity
) {
    // Extract size from bounds
    float2 size = bounds.zw;
 
    // Sample surrounding pixels for blur effect
    half4 blurredColor = currentColor;
 
    // Add slight color tint
    half4 tint = half4(1.0, 1.0, 1.0, 0.1);
    blurredColor = mix(blurredColor, tint, half(opacity));
 
    return blurredColor;
}

Animated Background Gradients

Like modern app login screens:

Shaders.metal
[[ stitchable ]] half4 animatedGradient(
    float2 position,
    float4 bounds,
    float time
) {
    // Extract size from bounds
    float2 size = bounds.zw;
    float2 uv = position / size;
 
    // Multiple moving gradient layers
    float2 offset1 = float2(sin(time * 0.5), cos(time * 0.3));
    float2 offset2 = float2(cos(time * 0.7), sin(time * 0.4));
 
    float dist1 = length(uv - 0.5 + offset1 * 0.2);
    float dist2 = length(uv - 0.5 + offset2 * 0.2);
 
    half4 color1 = half4(0.3, 0.5, 1.0, 1.0); // Blue
    half4 color2 = half4(1.0, 0.3, 0.7, 1.0); // Pink
    half4 color3 = half4(0.5, 1.0, 0.5, 1.0); // Green
 
    half4 blend = mix(color1, color2, half(dist1));
    blend = mix(blend, color3, half(dist2));
 
    return blend;
}

Text Shimmer Effect

Animated shimmer for premium UI:

Shaders.metal
[[ stitchable ]] half4 shimmerEffect(
    float2 position,
    half4 currentColor,
    float4 bounds,
    float time
) {
    // Extract size from bounds
    float2 size = bounds.zw;
    float2 uv = position / size;
 
    // Diagonal sweep
    float shimmer = sin((uv.x + uv.y) * 5.0 - time * 3.0);
    shimmer = smoothstep(0.3, 0.7, shimmer);
 
    // Brighten during shimmer
    half3 brightColor = currentColor.rgb * half(1.0 + shimmer * 0.5);
 
    return half4(brightColor, currentColor.a);
}

Usage:

Text("Premium Feature")
    .font(.system(size: 40, weight: .bold))
    .foregroundStyle(
        .linearGradient(
            colors: [.blue, .purple],
            startPoint: .leading,
            endPoint: .trailing
        )
    )
    .colorEffect(
        ShaderLibrary.shimmerEffect(
            .boundingRect,
            .float(time)
        )
    )

Part 6: Debugging Shaders

Common Issues and Solutions

Issue: Shader doesn't appear

// Solution: Check that [[stitchable]] attribute is present
[[ stitchable ]] half4 myShader(float2 position) { ... }

Issue: Colors look wrong

// Use half4 for colors (0.0 to 1.0 range)
half4 red = half4(1.0, 0.0, 0.0, 1.0); // ✅
half4 red = half4(255, 0, 0, 1); // ❌

Issue: Distortion doesn't work

// Make sure maxSampleOffset is large enough
.distortionEffect(
    ShaderLibrary.waveDistortion(...),
    maxSampleOffset: CGSize(width: 50, height: 50) // Increase if needed
)

Visualization Techniques

Debug by visualizing intermediate values:

[[ stitchable ]] half4 debugShader(float2 position, float4 bounds) {
    // Extract size from bounds
    float2 size = bounds.zw;
    float2 uv = position / size;
 
    // Visualize UV coordinates
    return half4(half(uv.x), half(uv.y), 0.0, 1.0);
}

Complete Example: Particle Field

Here's a complete, production-ready shader effect:

Particle field shader effect
Shaders.metal
[[ stitchable ]] half4 particleField(
    float2 position,
    float4 bounds,
    float time
) {
    // Extract size from bounds
    float2 size = bounds.zw;
    float2 uv = position / size;
 
    // Create multiple particle layers
    half4 finalColor = half4(0.0);
 
    for (int i = 0; i < 3; i++) {
        float speed = float(i + 1) * 0.3;
        float scale = float(i + 1) * 2.0;
 
        // Particle position with noise-like movement
        float2 particleUV = fract(uv * scale + time * speed);
 
        // Distance to center of each tile
        float dist = length(particleUV - 0.5);
 
        // Create glowing particles
        float particle = smoothstep(0.15, 0.0, dist);
 
        // Color based on layer
        half4 layerColor;
        if (i == 0) layerColor = half4(0.3, 0.5, 1.0, 1.0);
        else if (i == 1) layerColor = half4(1.0, 0.3, 0.7, 1.0);
        else layerColor = half4(0.5, 1.0, 0.5, 1.0);
 
        finalColor += layerColor * half(particle * 0.3);
    }
 
    return finalColor;
}

SwiftUI implementation:

struct ParticleFieldView: View {
    @State private var time: Float = 0
 
    var body: some View {
        Rectangle()
            .frame(width: 400, height: 400)
            .colorEffect(
                ShaderLibrary.particleField(
                    .boundingRect,
                    .float(time)
                )
            )
            .onAppear {
                withAnimation(.linear(duration: 20).repeatForever(autoreverses: false)) {
                    time = 20
                }
            }
    }
}

Conclusion

You now have the knowledge to create stunning GPU-accelerated visual effects in SwiftUI using Metal shaders! From simple color filters to complex distortions and animations, shaders unlock a new level of visual sophistication in your apps.

Key Takeaways

✅ Shaders run on the GPU for incredible performance ✅ Three types: color, distortion, and layer effects ✅ Use [[stitchable]] attribute for SwiftUI compatibility ✅ Optimize by avoiding branches and using appropriate precision ✅ Combine effects for complex visuals

Production Example

At Backdrop, we use advanced Metal shaders to process 4K video wallpapers in real-time with only 0.3% CPU usage. The GPU acceleration allows us to layer effects, apply filters, and animate transitions smoothly while maintaining exceptional battery efficiency.

Further Learning

Start experimenting with shaders in your projects. The creative possibilities are endless! 🎨✨