Build Mesh Gradients In SwiftUI

SwiftUI has had linear, radial, and angular gradients for years, and they still cover a lot of app UI. A mesh gradient is for the moments where you want something more art-directed: a soft wash of color behind a card, a background that feels connected to brand colors, or a surface that can gently change state without becoming a whole custom shader project.

MeshGradient is available on iOS 18, iPadOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, and newer. The examples below use SwiftUI only. No Metal file, no shader library, and no custom drawing layer.

Start with a small mesh

A mesh gradient is a two-dimensional grid. In the simple initializer used here, width is the number of vertices per row and height is the number of vertices per column. You then provide one SIMD2<Float> position and one Color for each vertex. Those coordinates are in the gradient’s unit space, so x and y run from 0 to 1 across the view or shape being filled.

Here is a 3 by 3 mesh:

import SwiftUI

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
enum BrandMesh {
    static var gradient: MeshGradient {
        MeshGradient(
            width: 3,
            height: 3,
            points: [
                .init(0.00, 0.00), .init(0.50, 0.00), .init(1.00, 0.00),
                .init(0.00, 0.50), .init(0.55, 0.45), .init(1.00, 0.50),
                .init(0.00, 1.00), .init(0.50, 1.00), .init(1.00, 1.00)
            ],
            colors: [
                .indigo, .blue, .teal,
                .purple, .pink, .cyan,
                .orange, .yellow, .mint
            ],
            background: .black,
            smoothsColors: true
        )
    }
}

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
struct BrandMeshBackground: View {
    var body: some View {
        BrandMesh.gradient
    }
}

The arrays are row-major: top left to top right, then the next row, and so on. For a 3 by 3 mesh, points and colors must each contain 9 values. If you change the mesh to 4 by 3, each array needs 12 values. The background color fills any area outside the defined vertex mesh, and smoothsColors: true asks SwiftUI to use cubic interpolation for the colors as well as the mesh shape.

The middle point in that example is slightly off center. Small changes like that are usually more useful than dramatic ones. If several points sit close together, the transition between their colors gets tighter. If the points are evenly spaced, the result feels more like a traditional gradient.

Use it as a view or a style

MeshGradient works as both a View and a ShapeStyle. That means you can put it directly in a ZStack, or use it to fill a shape.

For a background, I usually let the mesh cover the whole available surface and put legible content on top of a material or a subtle scrim:

struct WelcomeCard: View {
    var body: some View {
        ZStack {
            if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
                BrandMeshBackground()
                    .ignoresSafeArea()
            } else {
                LinearGradient(
                    colors: [.indigo, .teal],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            }

            VStack(alignment: .leading, spacing: 12) {
                Text("Today")
                    .font(.largeTitle.bold())

                Text("Three tasks are ready for review.")
                    .font(.headline)
            }
            .padding(24)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 24))
            .padding()
        }
    }
}

For a smaller accent, use the same gradient as a fill:

if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
    RoundedRectangle(cornerRadius: 24, style: .continuous)
        .fill(BrandMesh.gradient)
        .frame(height: 180)
}

Because the construction lives in BrandMesh.gradient, the shape fill and full-screen background use the same source of truth. That matters once you start tuning point positions and color palettes by hand.

Make the mesh reusable

The easiest version to reuse is a view that takes an animation phase. The parent can decide whether the phase changes over time, while the mesh only describes how points move.

import SwiftUI

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
struct AnimatedBrandMesh: View {
    let phase: Double

    private var wave: Float {
        Float(sin(phase) * 0.16)
    }

    private var points: [SIMD2<Float>] {
        [
            .init(0.00, 0.00), .init(0.50 + wave, 0.00), .init(1.00, 0.00),
            .init(0.00, 0.52), .init(0.56 - wave, 0.46 + wave), .init(1.00, 0.48),
            .init(0.00, 1.00), .init(0.50 - wave, 1.00), .init(1.00, 1.00)
        ]
    }

    var body: some View {
        MeshGradient(
            width: 3,
            height: 3,
            points: points,
            colors: [
                .indigo, .blue, .cyan,
                .purple, .pink, .teal,
                .orange, .yellow, .mint
            ],
            background: .black,
            smoothsColors: true,
            colorSpace: .perceptual
        )
    }
}

I like animating point positions before animating the color palette. Moving points preserves the overall identity of the surface while giving it a little life. Animating colors can work too, but it is easier to drift into a restless background that fights the foreground content.

The colorSpace parameter controls how SwiftUI interpolates between vertex colors. The default is .device. For UI work, .perceptual is often worth trying because it can make blends feel more natural to the eye. Treat it as part of the design, not a magic setting. Check the result in light mode, dark mode, and any high-contrast configurations your app supports.

Respect reduced motion

Mesh gradients are often decorative, which makes them a good place to respect accessibility settings. If someone has Reduce Motion enabled, freeze the mesh at a calm phase and keep the rest of the UI fully functional.

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
struct MotionAwareMeshBackground: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        if reduceMotion {
            AnimatedBrandMesh(phase: 0)
        } else {
            TimelineView(.animation) { context in
                AnimatedBrandMesh(
                    phase: context.date.timeIntervalSinceReferenceDate / 6
                )
            }
        }
    }
}

TimelineView(.animation) asks SwiftUI for animation-timed updates, which is a nice fit for a continuously moving background. Dividing the reference time slows the movement down. The actual number is taste, but slow is usually better here. A mesh should make the screen feel considered, not make the user wonder whether something is loading.

Keep text out of the chaos

Mesh gradients can produce bright local contrast and surprising intersections as points move. That is part of their appeal, but it also means you should be careful with text.

For decorative backgrounds, put text on a material, a solid color, or a controlled overlay. If the mesh sits inside a card, keep the card’s real content in a separate layer. For status surfaces, do not rely on the gradient alone to communicate meaning. Pair it with text, symbols, or layout changes that remain understandable in grayscale and high contrast.

It is also worth testing screenshots. A slow animation can look beautiful in motion and awkward in the exact frame used for a share sheet, app review screenshot, or social preview. If the background is important to your brand, give yourself a few fixed phases that you know look good.

Add a fallback

If your app still supports older operating systems, hide the mesh behind an availability boundary and use a simpler gradient as the fallback.

struct AppBackground: View {
    var body: some View {
        if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
            MotionAwareMeshBackground()
        } else {
            LinearGradient(
                colors: [.indigo, .blue, .teal],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        }
    }
}

That is enough for most apps. Older users get a good-looking static background, newer users get the richer color treatment, and the rest of your view tree does not need to know which implementation is active.

Practical wrap-up

Start with a 3 by 3 mesh, keep your point and color arrays aligned, and move points slowly if you want animation. Use MeshGradient directly for large backgrounds or as a ShapeStyle when filling a specific shape. Most importantly, design the foreground content as if the background might be busy, because sometimes it will be.

Further reading: