SwiftUI Metal Shaders in iOS 26: colorEffect, distortionEffect, and layerEffect Complete Guide

Build SwiftUI Metal shaders in iOS 26 with colorEffect, distortionEffect, and layerEffect. Includes MSL setup, TimelineView animation, and GPU cost tips with working code.

SwiftUI Metal Shaders Guide (iOS 26)

Updated: July 3, 2026

SwiftUI Metal shaders let you apply custom GPU-accelerated visual effects to any view by calling .colorEffect(), .distortionEffect(), or .layerEffect() with a Metal Shading Language function loaded from your app bundle. In iOS 26 these three modifiers are still the fastest way to reach the GPU from SwiftUI. You write a short .metal file, reference the function via ShaderLibrary, and SwiftUI runs it per-pixel on every frame. I hit almost every gotcha in this pipeline shipping a Liquid Glass card view last spring, so this guide walks through the whole flow: adding a Metal file, wiring arguments, animating with TimelineView, and fixing the common Xcode 26 pitfalls that stop shaders from rendering.

  • .colorEffect() recolors each pixel, .distortionEffect() moves each pixel, and .layerEffect() samples the whole layer. Pick the right one to keep GPU cost low.
  • Shaders are ordinary Metal Shading Language (MSL) functions with a required first parameter of float2 position and either half4 or SwiftUI::Layer as the second.
  • Metal shader modifiers require iOS 17+, but iOS 26 adds better previews, Liquid Glass composition, and improved ShaderLibrary diagnostics in Xcode 26.
  • Animate shaders by wrapping the view in TimelineView(.animation) and passing the time as a float argument. Never store per-frame state in Swift.
  • .layerEffect() is the most expensive because it forces off-screen rasterization; use maxSampleOffset to bound the sample radius.
  • Shaders run on iPhone, iPad, Mac Catalyst, macOS, tvOS, and visionOS, but not directly inside Image(systemName:) without wrapping in a view container.

How Metal shaders work in SwiftUI

A SwiftUI Metal shader is a function you write in Metal Shading Language (MSL) that the GPU runs once per pixel of the view you attach it to. SwiftUI compiles your .metal file at build time, loads the resulting binary into a ShaderLibrary, and every time the view redraws it dispatches your function against the view's rasterized output. Because the GPU processes millions of pixels in parallel, effects that would drop frames on the CPU (waveform distortions, per-pixel color grading, chromatic aberration) run smoothly at 60fps or 120fps ProMotion.

Three View modifiers expose this pipeline: colorEffect, distortionEffect, and layerEffect. Each takes a Shader value that pairs a function name with its argument list. Under the hood, SwiftUI records the shader into its render tree the same way it records a background color or a rotation, which means shaders compose with other modifiers, animate through the standard transaction system, and cooperate with the iOS 26 Liquid Glass renderer without extra work.

If you're new to GPU work, treat MSL as a C++ dialect with vector math built in. The two shader types you'll write in this guide are 10 to 20 lines each. Not scary.

colorEffect vs distortionEffect vs layerEffect

The three modifiers differ in what the GPU is allowed to touch, which directly determines their cost. Pick the least-powerful one that solves your problem. Using .layerEffect for a job that .colorEffect would handle wastes an entire off-screen render pass.

FeaturecolorEffectdistortionEffectlayerEffect
What it changesPixel colorPixel positionWhole layer sampling
Return typehalf4float2half4
Reads neighboring pixelsNoNoYes (via SwiftUI::Layer)
Needs maxSampleOffsetNoYesYes
Typical usesTints, thresholds, chromatic aberrationWaves, ripples, warpingBlur, glass, pixelate
Off-screen render passNoNoYes
Relative GPU costLowLow to mediumHigh
Minimum OSiOS 17iOS 17iOS 17

As a rule of thumb: if you only need to change a pixel's RGBA, use colorEffect. If you need to move pixels but keep colors, use distortionEffect. Only reach for layerEffect when you need to combine several pixels, for example a blur that averages a 5x5 neighborhood, or a "shatter" effect that samples from multiple source coordinates. The official colorEffect documentation covers the exact function signatures for each variant.

Set up a Metal file in Xcode 26

SwiftUI does not accept inline shader strings. The code must live in a .metal file that Xcode compiles into your app bundle. In Xcode 26 the workflow is: File → New → File from Template → Metal File. Name it something descriptive like VisualEffects.metal and let Xcode add it to your target. The build system detects the file automatically and produces a default.metallib at build time, so you don't need to touch build settings.

Every SwiftUI shader function needs two things at the top of the file: the metal_stdlib header and the SwiftUI namespace import. Add this preamble once per file:

#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>

using namespace metal;

The SwiftUI_Metal.h header exposes the SwiftUI::Layer type used by layerEffect. Omit it and you'll get an "unknown type name 'SwiftUI::Layer'" error the first time you touch a layer sampler. Xcode 26 improved these diagnostics: previously the compiler pointed at the wrong line, now it flags the missing header directly. If you're organizing your project structure alongside SwiftUI views, our SwiftUI Canvas and TimelineView guide walks through a similar layout for custom drawing code.

Write your first color shader

So, let's start with the simplest possible shader: swap the red and blue channels. It's a 3-line function that shows the exact signature every colorEffect shader must follow.

// VisualEffects.metal
[[ stitchable ]] half4 swapRB(float2 position, half4 color) {
    return half4(color.b, color.g, color.r, color.a);
}

The [[ stitchable ]] attribute is required. It tells the Metal compiler this function will be linked into SwiftUI's render pipeline at runtime rather than dispatched as a full compute kernel. The first parameter float2 position gives you the pixel's location in view coordinates (points, not pixels, since Retina scaling is handled for you). The second parameter half4 color is the pixel's current RGBA color as a 16-bit half-float vector.

Now attach it from Swift:

import SwiftUI

struct SwapRBView: View {
    var body: some View {
        Image(.sunset)
            .resizable()
            .scaledToFit()
            .colorEffect(ShaderLibrary.swapRB())
    }
}

ShaderLibrary is a dynamic member lookup type. Writing ShaderLibrary.swapRB() generates a Shader that references the function named swapRB in your default Metal library. Honestly, this is where I've been burned before: if you rename the Metal function, Xcode won't catch it at compile time. You'll get a runtime warning ("shader function not found") the first time the view draws. Keep names in sync.

Pass arguments from Swift to Metal

Real shaders take parameters: a tint color, a wave amplitude, a threshold. You pass them by calling the shader function from Swift with the arguments in order, matching the MSL function signature after the required position and color/layer parameters.

// VisualEffects.metal
[[ stitchable ]] half4 tint(float2 position, half4 color, half4 target) {
    // Preserve alpha, mix RGB toward target proportional to luminance.
    half luma = dot(color.rgb, half3(0.2126h, 0.7152h, 0.0722h));
    return half4(mix(color.rgb, target.rgb, luma), color.a);
}
struct TintedImage: View {
    @State private var target: Color = .orange

    var body: some View {
        Image(.sunset)
            .resizable()
            .scaledToFit()
            .colorEffect(
                ShaderLibrary.tint(.color(target))
            )
            .animation(.easeInOut(duration: 0.6), value: target)
    }
}

The Shader.Argument helpers convert Swift values into GPU-safe types: .color(_:) for half4/float4, .float(_:) for scalars, .float2(_:)/.float3(_:)/.float4(_:) for vectors, and .image(_:) for auxiliary textures. Watch the order. SwiftUI passes them positionally, not by name, and mismatches cause silent visual corruption rather than compiler errors.

Animate shaders with TimelineView

SwiftUI won't automatically re-invoke a shader on every frame. It only redraws when the view's data changes. To animate a shader you have to drive continuous redraws yourself, and the idiomatic tool for that is TimelineView(.animation). It ticks at the display's refresh rate (60Hz or 120Hz ProMotion) and hands you a timeline.date, which you convert to a Float and pass as a shader argument.

[[ stitchable ]] half4 pulse(float2 position, half4 color, float time) {
    half wave = half(0.5 + 0.5 * sin(time * 3.0));
    return half4(color.rgb * wave, color.a);
}
struct PulsingView: View {
    let start = Date()

    var body: some View {
        TimelineView(.animation) { timeline in
            let elapsed = Float(timeline.date.timeIntervalSince(start))
            Image(.logo)
                .resizable()
                .scaledToFit()
                .colorEffect(
                    ShaderLibrary.pulse(.float(elapsed))
                )
        }
    }
}

Every tick, SwiftUI rebuilds the view body with a new elapsed value, which invalidates the shader arguments and triggers a redraw. On a ProMotion iPhone this runs at 120Hz with no measurable CPU cost. The GPU does all the work. If your effect stutters, the culprit is almost always the CPU side (heavy view body recomputation, complex layout, or an unnecessary @State update per frame), not the shader itself. Our SwiftUI Canvas and TimelineView guide covers the same time-driven pattern for CPU drawing.

Build a wave distortion effect

Distortion shaders don't return a color. They return a new float2 position that SwiftUI samples the source view at. Return the original position and nothing changes; offset it and you warp the view. The classic example is a sine-wave ripple:

[[ stitchable ]] float2 wave(float2 position, float time, float amplitude) {
    float y = position.y + sin(position.x * 0.04 + time * 2.0) * amplitude;
    return float2(position.x, y);
}
struct WavyText: View {
    let start = Date()

    var body: some View {
        TimelineView(.animation) { timeline in
            let elapsed = Float(timeline.date.timeIntervalSince(start))
            Text("SwiftUI Metal Shaders")
                .font(.system(size: 44, weight: .bold, design: .rounded))
                .distortionEffect(
                    ShaderLibrary.wave(
                        .float(elapsed),
                        .float(6.0)
                    ),
                    maxSampleOffset: CGSize(width: 0, height: 8)
                )
        }
    }
}

The maxSampleOffset parameter is critical: it tells SwiftUI how far outside the view's natural bounds your shader might sample, so the render system can allocate enough backing texture. If your shader offsets pixels by up to 8 points vertically, pass CGSize(width: 0, height: 8). Under-report the bound and you'll see clipped edges. Over-report and you waste GPU memory. Values around 4 to 16 points are typical for subtle distortions.

Build a layer effect: frosted blur

The third modifier, layerEffect, gives your shader access to the whole rasterized layer via SwiftUI::Layer. You can call layer.sample(coord) to read any pixel, inside or outside the "current" position, which unlocks blurs, convolutions, and any effect that mixes multiple source pixels. Here's a lightweight box blur:

[[ stitchable ]] half4 boxBlur(float2 position, SwiftUI::Layer layer, float radius) {
    half4 sum = half4(0.0);
    float samples = 0.0;
    for (float dy = -radius; dy <= radius; dy += 1.0) {
        for (float dx = -radius; dx <= radius; dx += 1.0) {
            sum += layer.sample(position + float2(dx, dy));
            samples += 1.0;
        }
    }
    return sum / half(samples);
}
struct FrostedCard: View {
    @State private var blur: Float = 6.0

    var body: some View {
        VStack {
            Text("Frosted")
                .font(.largeTitle.bold())
            Text("Custom Metal blur")
                .font(.title3)
        }
        .padding(40)
        .background(.thinMaterial)
        .layerEffect(
            ShaderLibrary.boxBlur(.float(blur)),
            maxSampleOffset: CGSize(width: 6, height: 6)
        )
    }
}

A 6-pixel-radius box blur samples 169 pixels per output pixel. Expensive but tractable at moderate view sizes. For anything larger, use a two-pass Gaussian (blur horizontally, then vertically) which reduces cost from O(r²) to O(r). If you're stacking blur on top of the iOS 26 Liquid Glass material, see our reusable glass effect component guide for patterns that combine shaders with system materials.

Performance and GPU cost

Shaders feel free (60fps out of the box, no visible CPU work) but there are three cost centers worth understanding.

First, fragment shader complexity. A colorEffect that does three arithmetic ops per pixel is essentially free; one that runs a 32-iteration loop per pixel is not. Profile with Xcode's Metal Frame Capture (Debug → Capture GPU Frame) and look at the shader's "fragment invocations" and "ALU utilization". If either red-flags, simplify the math or reduce the affected area.

Second, off-screen passes for layerEffect. Every layerEffect forces SwiftUI to rasterize the view into an off-screen texture before your shader runs. That texture allocation is not free, especially on older devices. A 400x400 point layer at 3x scale is a 1200x1200 texture. Cache the layer by giving it a stable identity, and don't animate its size unless you have to.

Third, overdraw from maxSampleOffset. The offset expands the render target by that padding on all sides. A 100-point maxSampleOffset on a 300-point view doubles the pixel count. Report only what your shader actually needs.

On A17 Pro and later, all three shader types comfortably hit 120Hz ProMotion for full-screen effects. On A15 (iPhone 13), stay under 60Hz for full-screen layerEffect or scope the effect to a smaller region. For deeper animation optimization, our SwiftUI animations guide covers the transaction system that shaders inherit.

Common errors and fixes

Metal shader errors in SwiftUI are notoriously terse. The five you'll hit most often:

  1. "Shader function not found in default library". The function name in ShaderLibrary.xxx doesn't match your MSL function, or you forgot the [[ stitchable ]] attribute. Check both.
  2. "Unknown type name 'SwiftUI::Layer'". Missing #include <SwiftUI/SwiftUI_Metal.h>. Add it to the top of the file.
  3. Shader compiles but nothing renders. You attached the modifier to a view with no rasterized content (e.g., an empty Color.clear with no frame). Give the view an explicit frame or content.
  4. Effect works on iPhone but not in Preview. Outdated Xcode. Metal previews require Xcode 15.3+; Xcode 26 is fully supported. Force a preview refresh with Option + Cmd + P.
  5. Edges of a distortion effect are clipped. Your maxSampleOffset is too small. Increase both dimensions until the clipping stops.

Apple's Metal Shading Language specification is the ground truth for MSL syntax and the built-in math library. Keep it open in a browser tab while you're writing shaders. The metal_stdlib functions like smoothstep, mix, and fract do a lot of heavy lifting.

Frequently Asked Questions

What is the difference between colorEffect and layerEffect in SwiftUI?

colorEffect transforms a single pixel's color based only on its own value, while layerEffect gives your shader access to the entire rasterized layer via SwiftUI::Layer.sample(). Use colorEffect for tints and per-pixel color grading; use layerEffect for blurs and effects that need to read neighboring pixels.

Can you animate SwiftUI Metal shaders?

Yes. Wrap the shader-attached view in TimelineView(.animation) and pass the elapsed time as a Float shader argument. SwiftUI redraws on every display refresh (up to 120Hz on ProMotion), and the shader uses the new time value to compute each frame.

Are Metal shaders performant on iOS?

Extremely. The GPU runs shaders in parallel across thousands of pixels. A colorEffect shader adds essentially no measurable cost. layerEffect is more expensive because it forces an off-screen rasterization pass, but full-screen effects still hit 60fps easily on A15 and 120fps on A17 Pro and later.

Do you need to write Metal Shading Language to use SwiftUI shaders?

Yes. SwiftUI's shader modifiers don't accept inline shader code. You must write a .metal file containing an MSL function marked with the [[ stitchable ]] attribute. MSL is a C++ dialect; the shaders you'll write are typically 5 to 20 lines.

Do SwiftUI Metal shaders work on iPad, Mac, and visionOS?

Yes. The colorEffect, distortionEffect, and layerEffect modifiers work on iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+, and visionOS 1+. iOS 26 adds better preview support and cleaner composition with the Liquid Glass renderer, but the underlying API is identical across platforms.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.