```html SwiftUI: How to Build Confetti Animation (iOS 17+, 2026)

How to Build a Confetti Animation in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Canvas, TimelineView, Animation Updated: May 11, 2026
TL;DR

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

  1. TimelineView(.animation) — fires on every display-refresh frame (typically 60 or 120 Hz) and passes a TimelineViewDefaultContext whose .date property is the current timestamp. This is the engine that drives the animation without any manual Timer or DispatchQueue.
  2. Birth-time physics — instead of mutating each particle's position each frame (which would require 120 @State writes), every particle stores its birth time. Position is computed as x = x₀ + vx·t and y = y₀ + vy·t + ½·g·t², giving physically realistic parabolic arcs with zero state mutations.
  3. Canvas draw pass — all 120 particles are rendered inside a single Canvas closure. 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.
  4. Opacity fade + rotationopacity = 1 − age/lifetime creates a smooth fade-out, and angle = rotationSpeed · age makes each piece tumble independently. Both are pure functions of t, requiring no stored state per particle.
  5. Pruning dead particlespruneIfNeeded runs every 2 seconds via onChange(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

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?
Partially. 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?
On an iPhone 15 Pro, 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.

```