How to Build a Confetti Animation in SwiftUI
Use TimelineView(.animation) to drive a per-frame
update loop and Canvas to batch-render all confetti
particles in a single GPU pass — no UIKit, no CAEmitterLayer needed.
struct ConfettiView: View {
@State private var particles: [Particle] = []
var body: some View {
TimelineView(.animation) { context in
Canvas { ctx, size in
let now = context.date.timeIntervalSinceReferenceDate
for p in particles {
let age = now - p.birth
let x = p.x + p.vx * age
let y = p.y + p.vy * age + 0.5 * 300 * age * age
ctx.fill(
Path(ellipseIn: CGRect(x: x, y: y, width: 8, height: 8)),
with: .color(p.color.opacity(max(0, 1 - age / p.lifetime)))
)
}
}
}
.onTapGesture { burst() }
}
}
Full implementation
The implementation centres on two SwiftUI primitives: TimelineView provides a
continuously firing schedule at display-refresh rate, and Canvas gives us an
immediate-mode drawing context where we can render hundreds of particles in one declarative pass.
Each Particle records its birth timestamp
rather than mutable position so physics are computed purely from elapsed time — no
@State churn per particle.
import SwiftUI
// MARK: - Particle model
struct Particle: Identifiable {
let id = UUID()
let birth: TimeInterval // Date.timeIntervalSinceReferenceDate at spawn
let lifetime: Double // seconds until fully transparent
let x: Double // initial X position
let y: Double // initial Y position
let vx: Double // horizontal velocity (pts/s)
let vy: Double // vertical velocity (pts/s, negative = upward)
let color: Color
let shape: ParticleShape
let size: Double
let rotationSpeed: Double // radians/s
enum ParticleShape { case circle, rect, triangle }
}
// MARK: - ConfettiView
struct ConfettiView: View {
@State private var particles: [Particle] = []
@State private var lastPruneTime: TimeInterval = 0
private let gravity: Double = 400 // pts/s²
private let palette: [Color] = [
.red, .orange, .yellow, .green,
.blue, .purple, .pink, .cyan
]
var body: some View {
TimelineView(.animation) { timeline in
let now = timeline.date.timeIntervalSinceReferenceDate
Canvas { ctx, size in
for p in particles {
let age = now - p.birth
guard age < p.lifetime else { continue }
let x = p.x + p.vx * age
let y = p.y + p.vy * age + 0.5 * gravity * age * age
let opacity = max(0, 1 - age / p.lifetime)
let angle = p.rotationSpeed * age
ctx.opacity = opacity
ctx.translateBy(x: x + p.size / 2, y: y + p.size / 2)
ctx.rotate(by: Angle(radians: angle))
ctx.translateBy(x: -(p.size / 2), y: -(p.size / 2))
let rect = CGRect(origin: .zero, size: CGSize(width: p.size, height: p.size * 0.5))
switch p.shape {
case .circle:
ctx.fill(Path(ellipseIn: rect), with: .color(p.color))
case .rect:
ctx.fill(Path(rect), with: .color(p.color))
case .triangle:
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
ctx.fill(path, with: .color(p.color))
}
// Reset transform for next particle
ctx.translateBy(x: p.size / 2, y: p.size * 0.25)
ctx.rotate(by: Angle(radians: -angle))
ctx.translateBy(x: -(x + p.size / 2), y: -(y + p.size * 0.25))
ctx.opacity = 1
}
}
.onChange(of: now) { _, newTime in
pruneIfNeeded(now: newTime)
}
}
.onTapGesture(perform: burst)
.accessibilityLabel("Confetti animation. Tap to trigger.")
.ignoresSafeArea()
}
// MARK: - Burst
private func burst() {
let now = Date.timeIntervalSinceReferenceDate
let cx = UIScreen.main.bounds.midX
let cy = UIScreen.main.bounds.midY * 0.6
let shapes = Particle.ParticleShape.allCases ?? [.circle, .rect, .triangle]
let newParticles: [Particle] = (0..<120).map { _ in
let angle = Double.random(in: 0...(2 * .pi))
let speed = Double.random(in: 200...600)
return Particle(
birth: now,
lifetime: Double.random(in: 1.5...3.0),
x: cx + Double.random(in: -20...20),
y: cy,
vx: cos(angle) * speed,
vy: sin(angle) * speed - 300,
color: palette.randomElement()!,
shape: [Particle.ParticleShape.circle, .rect, .triangle].randomElement()!,
size: Double.random(in: 6...14),
rotationSpeed: Double.random(in: -6...6)
)
}
particles.append(contentsOf: newParticles)
}
// MARK: - Prune dead particles (every 2 s)
private func pruneIfNeeded(now: TimeInterval) {
guard now - lastPruneTime > 2 else { return }
lastPruneTime = now
particles.removeAll { now - $0.birth > $0.lifetime + 0.1 }
}
}
// MARK: - Preview
#Preview {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack {
Text("Tap anywhere for confetti 🎉")
.font(.headline)
.padding(.top, 60)
Spacer()
}
ConfettiView()
}
}
How it works
-
TimelineView(.animation) — fires on every display-refresh frame (typically 60 or
120 Hz) and passes a
TimelineViewDefaultContextwhose.dateproperty is the current timestamp. This is the engine that drives the animation without any manualTimerorDispatchQueue. -
Birth-time physics — instead of mutating each particle's position each frame
(which would require 120
@Statewrites), every particle stores its birth time. Position is computed asx = x₀ + vx·tandy = y₀ + vy·t + ½·g·t², giving physically realistic parabolic arcs with zero state mutations. -
Canvas draw pass — all 120 particles are rendered inside a single
Canvasclosure. Canvas uses a Core Graphics–backed immediate-mode context, so the entire frame is committed in one GPU call rather than creating 120 SwiftUI view nodes in the render tree. -
Opacity fade + rotation —
opacity = 1 − age/lifetimecreates a smooth fade-out, andangle = rotationSpeed · agemakes each piece tumble independently. Both are pure functions of t, requiring no stored state per particle. -
Pruning dead particles —
pruneIfNeededruns every 2 seconds viaonChange(of: now)and removes particles whose age exceeds their lifetime, keeping the array from growing unboundedly across many taps.
Variants
Trigger from a button with a completion callback
Expose a Binding<Bool> so a parent
view (e.g. a "Purchase complete" screen) can fire the burst declaratively.
struct ConfettiView: View {
@Binding var trigger: Bool // parent flips this to true
var body: some View {
TimelineView(.animation) { timeline in
Canvas { ctx, size in
// … same draw code …
}
}
.onChange(of: trigger) { _, newValue in
if newValue {
burst()
// Reset after a short delay so it can re-trigger
Task {
try? await Task.sleep(for: .seconds(0.1))
trigger = false
}
}
}
}
}
// Usage in parent
struct PurchaseSuccessView: View {
@State private var showConfetti = false
var body: some View {
ZStack {
ConfettiView(trigger: $showConfetti)
Button("Buy") { showConfetti = true }
}
}
}
Emoji confetti
Replace the shape-drawing code in the Canvas closure with
ctx.draw(Text("🎊"), at: CGPoint(x: x, y: y)).
SwiftUI's Canvas can render resolved Text and Image symbols
directly — just resolve them outside the closure with
ctx.resolve(Text("🎊")) first for best performance.
Common pitfalls
-
⚠️ iOS 15 Canvas, iOS 17 TimelineView schedule —
Canvasshipped in iOS 15, butTimelineView(.animation)as a reliable per-frame driver was stabilised in iOS 17. Targeting iOS 16 means you may see dropped frames or irregular firing; keep your deployment target at iOS 17+ for this pattern. -
⚠️ Mutating particle state inside Canvas —
Canvas's closure is called on the main thread but is not a view builder; mutating@Stateinside it causes undefined behaviour. Always compute positions from birth-time math and keep mutations (like pruning) inonChangeoronTapGesture. -
⚠️ Particle array growth on rapid taps — each tap adds 120 particles. Without
pruning, 20 rapid taps = 2 400 particles computed per frame. Either cap the array
(
particles = Array(particles.suffix(300))) or throttle the burst gesture with adebounce. -
⚠️ Accessibility / Reduce Motion — always check
@Environment(\.accessibilityReduceMotion)and skip or slow down the animation for users who have enabled Reduce Motion in Settings.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a confetti animation in SwiftUI for iOS 17+. Use Canvas and TimelineView(.animation). Support circle, rectangle, and triangle particle shapes. Respect accessibilityReduceMotion — skip animation when enabled. Make it accessible (VoiceOver labels, accessibilityLabel on Canvas). Expose a Binding<Bool> trigger so parent views can fire bursts. Add a #Preview with a realistic "Purchase Complete" success screen.
In Soarias's Build phase, drop this prompt into the Implementation tab alongside your screen mockup
— Claude Code will scaffold the full ConfettiView file, wire the binding to your
existing checkout flow, and add a SwiftUI preview in one pass.
Related
FAQ
Does this work on iOS 16?
Canvas is available from iOS 15 and TimelineView from iOS 15
too, but the .animation schedule's frame-rate fidelity improved significantly in iOS 17.
On iOS 16 you may see the timeline fire less consistently. If you must support iOS 16, use
TimelineView(.periodic(from: .now, by: 1/60)) as a fallback, though it won't
automatically adapt to ProMotion 120 Hz displays.
How many particles can Canvas render at 60 fps?
Canvas comfortably renders 500–800 simple shapes (rects,
ellipses) at 60 fps. For 1 000+ particles, switch to Metal via
MetalKit or pre-resolve complex symbols with
ctx.resolve() outside the per-particle loop. Profile with the
Metal System Trace instrument before optimising — you likely won't need to.
What's the UIKit / CAEmitterLayer equivalent?
CAEmitterLayer with CAEmitterCell objects is the classic UIKit approach.
It hands physics entirely to Core Animation on a background thread, which can be more efficient
for sustained, long-lived particle systems (e.g. snow). The SwiftUI Canvas approach
is easier to customise (arbitrary Swift logic per particle), composes naturally with the view
hierarchy, and requires no UIViewRepresentable wrapper. For a celebratory one-shot
burst — the most common confetti use case — Canvas + TimelineView is the better
modern choice.
Last reviewed: 2026-05-11 by the Soarias team.