How to implement a particle effect in SwiftUI
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
TimelineView(.animation) drives the render loop. The
.animationschedule fires at the device's display link rate — 60 or 120 Hz. Each call delivers a freshtimeline.date, which we diff againstlastDateto compute an accurate delta timedt, even if a frame drops. -
2
Canvas skips SwiftUI's layout and diff pass. Unlike a
ForEachofCircle()views,Canvasrenders 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
The tick function mutates @State in-place.
compactMapon the particles array advances position byvelocity * dt, applies downward gravity (120 pts/s²), decrementslifetimebydecayRate * dt, and drops particles whose lifetime reaches zero — all in one pass. -
4
Opacity and radius both taper from lifetime. Setting
ctx.opacity = p.lifetimeandr = baseRadius * p.lifetimemeans particles naturally shrink and fade together as they age, requiring zero extra state — the singlelifetimevalue drives both visual dimensions. -
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
-
⚠
Calling tick() inside Canvas's draw closure mutates @State during rendering. On iOS 17 this can trigger SwiftUI warnings or double-renders. Move the tick call to a
.onChange(of: timeline.date)modifier on theTimelineViewif you see stutters — but benchmark first, as the closure approach usually works fine in practice. -
⚠
Unbounded particle arrays will eventually eat memory. Cap the array with something like
if particles.count > 800 { particles.removeFirst(200) }before emitting, or let the decay system prune naturally and limit burst size viaparticlesPerBurst. -
⚠
Canvas is not automatically accessible. Add
.accessibilityLabel("Particle animation")and.accessibilityAddTraits(.isImage)to theCanvas, and respect@Environment(\.accessibilityReduceMotion)to skip animation entirely for users who have it enabled. -
⚠
Capping dt is essential. Without
min(dt, 0.05), backgrounding the app and foregrounding it produces a huge dt spike that teleports all particles off-screen in one frame.
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.