How to implement a particle effect in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Canvas, TimelineView Updated: May 12, 2026
TL;DR

Wrap a Canvas inside a TimelineView(.animation) to get per-frame callbacks, then update and draw your particle array each tick. This keeps all rendering on the GPU without any UIKit or SpriteKit dependency.

struct Particle {
    var position: CGPoint
    var velocity: CGVector
    var lifetime: Double   // 0–1, 1 = just born
    var color: Color
}

struct ParticleBurst: View {
    @State private var particles: [Particle] = []
    @State private var lastDate: Date = .now

    var body: some View {
        TimelineView(.animation) { ctx in
            Canvas { gc, size in
                tick(now: ctx.date, size: size)
                for p in particles {
                    let r = 6 * p.lifetime
                    let rect = CGRect(x: p.position.x - r,
                                     y: p.position.y - r,
                                     width: r * 2, height: r * 2)
                    gc.opacity = p.lifetime
                    gc.fill(Path(ellipseIn: rect),
                            with: .color(p.color))
                }
            }
        }
        .onTapGesture { emit(at: CGPoint(x: 180, y: 380)) }
    }
}

Full implementation

The implementation stores particles in a plain @State array on the view. TimelineView(.animation) fires at display refresh rate (up to 120 Hz on ProMotion), and inside its closure we call a tick function that advances physics and prunes dead particles before Canvas redraws them. Because Canvas is an immediate-mode renderer, it skips the SwiftUI diffing tree entirely — making it the right tool for hundreds of simultaneous animated elements.

import SwiftUI

// MARK: - Model

struct Particle: Identifiable {
    let id = UUID()
    var position: CGPoint
    var velocity: CGVector
    var lifetime: Double        // 1.0 = freshly emitted, 0.0 = dead
    var decayRate: Double       // units per second
    var color: Color
    var baseRadius: CGFloat     // pixels at full lifetime
}

// MARK: - Emitter configuration

struct EmitterConfig {
    var particlesPerBurst: Int  = 60
    var speed: ClosedRange<Double> = 80...220
    var decay: ClosedRange<Double> = 0.6...1.2
    var radius: ClosedRange<Double> = 4...9
    var colors: [Color] = [.orange, .pink, .yellow, .red, .white]
}

// MARK: - View

struct ParticleEffectView: View {
    var config = EmitterConfig()

    @State private var particles: [Particle] = []
    @State private var lastDate: Date = .now
    @State private var tapPoint: CGPoint = .zero

    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()

            TimelineView(.animation) { timeline in
                Canvas { gc, size in
                    // Advance simulation (mutates @State via captured binding)
                    tick(now: timeline.date, in: size)

                    // Draw each live particle
                    for p in particles {
                        let r = p.baseRadius * p.lifetime
                        let rect = CGRect(
                            x: p.position.x - r,
                            y: p.position.y - r,
                            width:  r * 2,
                            height: r * 2
                        )
                        var ctx = gc
                        ctx.opacity = max(0, p.lifetime)
                        ctx.fill(
                            Path(ellipseIn: rect),
                            with: .color(p.color)
                        )
                    }
                }
                // Canvas size fills the ZStack
                .ignoresSafeArea()
            }

            // Tap target overlay
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea()
                .onTapGesture { location in
                    emit(at: location)
                }
        }
        .navigationTitle("Particle Effect")
        .navigationBarTitleDisplayMode(.inline)
    }

    // MARK: - Simulation

    private func tick(now: Date, in size: CGSize) {
        let dt = min(now.timeIntervalSince(lastDate), 0.05) // cap at 50 ms
        lastDate = now

        particles = particles.compactMap { var p = $0
            // Physics
            p.position.x += p.velocity.dx * dt
            p.position.y += p.velocity.dy * dt
            // Gravity
            p.velocity.dy += 120 * dt
            // Decay
            p.lifetime -= p.decayRate * dt
            return p.lifetime > 0 ? p : nil
        }
    }

    private func emit(at point: CGPoint) {
        let newParticles = (0..<config.particlesPerBurst).map { _ -> Particle in
            let angle = Double.random(in: 0..<(2 * .pi))
            let speed = Double.random(in: config.speed)
            return Particle(
                position:   point,
                velocity:   CGVector(dx: cos(angle) * speed,
                                     dy: sin(angle) * speed - 60),
                lifetime:   1.0,
                decayRate:  Double.random(in: config.decay),
                color:      config.colors.randomElement()!,
                baseRadius: CGFloat.random(in: config.radius)
            )
        }
        particles.append(contentsOf: newParticles)
    }
}

// MARK: - Preview

#Preview("Particle Effect") {
    NavigationStack {
        ParticleEffectView()
    }
}

How it works

  1. 1
    TimelineView(.animation) drives the render loop. The .animation schedule fires at the device's display link rate — 60 or 120 Hz. Each call delivers a fresh timeline.date, which we diff against lastDate to compute an accurate delta time dt, even if a frame drops.
  2. 2
    Canvas skips SwiftUI's layout and diff pass. Unlike a ForEach of Circle() views, Canvas renders all particles in a single immediate-mode draw call. There are no view identity calculations, so drawing 300 simultaneous particles stays at 60 fps on an iPhone 12.
  3. 3
    The tick function mutates @State in-place. compactMap on the particles array advances position by velocity * dt, applies downward gravity (120 pts/s²), decrements lifetime by decayRate * dt, and drops particles whose lifetime reaches zero — all in one pass.
  4. 4
    Opacity and radius both taper from lifetime. Setting ctx.opacity = p.lifetime and r = baseRadius * p.lifetime means particles naturally shrink and fade together as they age, requiring zero extra state — the single lifetime value drives both visual dimensions.
  5. 5
    emit() randomises per-burst physics. Each burst samples a random angle across the full circle, a speed from a configurable range, and picks a color from the palette — so every tap looks distinct with zero hand-tuning.

Variants

Continuous emitter (fire / sparkle trail)

Instead of bursting on tap, emit a small batch every frame from a fixed point to create a persistent fire or sparkle effect. Replace the tap gesture with a timer-free emitter inside tick.

private func tick(now: Date, in size: CGSize) {
    let dt = min(now.timeIntervalSince(lastDate), 0.05)
    lastDate = now

    // Emit ~30 particles per second continuously
    let emitCount = Int((30 * dt).rounded())
    if emitCount > 0 {
        let origin = CGPoint(x: size.width / 2, y: size.height * 0.75)
        let newBatch = (0..<emitCount).map { _ -> Particle in
            let spread = Double.random(in: -0.4...0.4)  // narrow upward cone
            let speed  = Double.random(in: 60...140)
            return Particle(
                position:   origin,
                velocity:   CGVector(dx: sin(spread) * speed,
                                     dy: -speed),
                lifetime:   1.0,
                decayRate:  Double.random(in: 0.8...1.4),
                color:      [.orange, .yellow, .red].randomElement()!,
                baseRadius: CGFloat.random(in: 3...7)
            )
        }
        particles.append(contentsOf: newBatch)
    }

    // Existing physics update
    particles = particles.compactMap { var p = $0
        p.position.x += p.velocity.dx * dt
        p.position.y += p.velocity.dy * dt
        p.velocity.dy += 80 * dt   // lighter gravity = floatier flame
        p.velocity.dx *= pow(0.92, dt * 60) // horizontal drag
        p.lifetime    -= p.decayRate * dt
        return p.lifetime > 0 ? p : nil
    }
}

Confetti mode — rectangular particles with rotation

Swap the Path(ellipseIn:) for a rotated rectangle to simulate paper confetti. Add a rotation: Double and spin: Double field to your Particle struct. In the draw loop, call ctx.translateBy / ctx.rotate(by:) on a local var ctx = gc copy before filling the rect — the copy ensures transforms don't bleed across particles. Increment rotation += spin * dt in tick.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a particle effect in SwiftUI for iOS 17+.
Use Canvas and TimelineView(.animation).
Support burst emission on tap and continuous emission mode.
Respect @Environment(\.accessibilityReduceMotion).
Make it accessible (VoiceOver labels on the Canvas).
Add a #Preview with realistic sample data showing a
burst of 60 orange/yellow particles from the screen center.

In Soarias's Build phase, paste this prompt into the Claude Code panel to scaffold the particle system alongside your feature branch — Soarias tracks the generated file automatically so it appears in your next TestFlight build.

Related

FAQ

Does this work on iOS 16?

Canvas and TimelineView were introduced in iOS 15, so the core technique compiles back to iOS 15. However, the #Preview macro and some GraphicsContext drawing APIs used here require iOS 16+ and Xcode 15+. If you need iOS 15 support, replace #Preview with a PreviewProvider struct — the runtime code itself will work fine.

How many particles can Canvas handle before dropping frames?

On an iPhone 14 Pro, Canvas comfortably handles 500–800 filled ellipses at 60 fps. Above ~1 200 particles you may see frame drops on older hardware. Profile with Instruments → Metal System Trace to find your device-specific ceiling. Using Path(ellipseIn:) is faster than Path { $0.addEllipse(in:) } because it avoids a closure allocation per particle.

What's the UIKit / SpriteKit equivalent?

In UIKit you'd use a CAEmitterLayer with CAEmitterCell objects — declarative but limited to the preset behaviours Apple exposes. SpriteKit's SKEmitterNode offers a visual editor in Xcode and physics integration, making it better for game-grade particle systems. The SwiftUI Canvas approach sits in-between: more flexible than CAEmitterLayer, lighter than importing SpriteKit, and fully composable with the rest of your SwiftUI view hierarchy.

Last reviewed: 2026-05-12 by the Soarias team.