Skip to content

Physics-Based Confetti Animation in SwiftUI with Canvas and TimelineView

Introduction

Confetti animations are one of those details that turn a good user experience into a memorable one. Most implementations reach for a third-party package — drop in an SPM dependency, call a function, done. But what if you want full control over the physics, the shapes, the timing?

In this article, we'll build a confetti particle system entirely from scratch using SwiftUI's Canvas and TimelineView. No SpriteKit, no UIKit interop, no external packages. Just math and SwiftUI.

Why Canvas over SpriteKit? SpriteKit is powerful but heavy — it brings its own rendering pipeline, coordinate system, and lifecycle. For a decorative overlay like confetti, Canvas is lighter: it renders directly into the SwiftUI view hierarchy, composes naturally with other views, and gets Metal acceleration via .drawingGroup(). Combined with TimelineView for frame-by-frame updates, it's the sweet spot for custom animations that need to feel native.

By the end, you'll have a reusable ConfettiView that:

  • Simulates gravity, air drag, and terminal velocity
  • Adds realistic flutter (horizontal sway) and 3D tumble effects
  • Renders 30 particles at 60fps with minimal CPU overhead
  • Triggers from a simple @Binding toggle

The Particle Model

Every particle in a physics simulation carries its own state. Rather than computing positions from a shared clock, each ConfettiParticle stores everything needed to calculate its position at any point in time:

swift
struct ConfettiParticle: Identifiable {
    let id = UUID()
    let color: Color
    let size: CGSize

    // Initial conditions
    let initialPosition: CGPoint
    let initialVelocity: CGVector
    let initialRotation: Double
    let rotationSpeed: Double
    let createdAt: Date

    // Flutter (horizontal sway)
    let flutterAmplitude: CGFloat   // How far it sways (25-45 pts)
    let flutterFrequency: CGFloat   // How fast it sways (4-7 Hz)
    let flutterPhase: CGFloat       // Starting phase (0-2π)

    // 3D flip effect
    let flipFrequency: CGFloat      // Tumble speed (3-6 Hz)
    let flipPhase: CGFloat          // Starting flip angle (0-2π)

    // Spread
    let spreadStrength: CGFloat     // Outward drift intensity (8-18)
}

Why store all these parameters per-particle instead of computing them globally? Because randomization is what makes confetti look natural. Each piece of paper has a slightly different weight, catches air differently, and tumbles at its own rate. By randomizing parameters at spawn time, we get organic-looking motion without any runtime randomness — the system is fully deterministic after creation.

Physics Foundation

Real confetti doesn't fall at constant speed. It launches upward, decelerates, reaches a peak, then falls — accelerating until air resistance balances gravity at terminal velocity. We model this with two forces:

Gravity pulls particles downward at a constant acceleration:

gravity = 280 pts/s²

Air drag opposes velocity proportionally, creating exponential decay:

drag_coefficient = 1.6

The key insight is that velocity under drag doesn't decay linearly — it follows an exponential curve. The factor 1 - e^(-drag × t) gives us the fraction of initial velocity that's been "spent" by time t:

swift
let dragFactor = 1 - exp(-drag * t)  // Ranges from 0 to ~1

Terminal velocity is the speed where gravity and drag balance perfectly:

swift
let terminalVelocity = gravity / drag  // 280 / 1.6 = 175 pts/s

Physics Summary

ConstantValueEffect
Gravity280 pts/s²Downward pull
Drag1.6Air resistance (exponential decay)
Terminal velocity175 pts/sMaximum fall speed
Lifetime1.5 sHow long particles exist

Position Calculation

With the physics model defined, we can compute each particle's position at any time t:

Horizontal Position

The horizontal position combines three components — decelerated drift from the initial velocity, sinusoidal flutter, and quadratic spread:

swift
let posX = particle.initialPosition.x
    + (particle.initialVelocity.dx / drag) * dragFactor        // Decelerated drift
    + particle.flutterAmplitude * sin(                          // Flutter sway
        particle.flutterFrequency * t + particle.flutterPhase
      )
    + spreadOffset                                              // Outward drift

The first term handles initial horizontal momentum: (v₀ / drag) × (1 - e^(-drag×t)) gives the total distance traveled under exponential braking. As t → ∞, this approaches v₀ / drag — the maximum distance the initial push can carry.

The spread offset pushes particles outward as they fall, using for gentle acceleration:

swift
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * t

Vertical Position

The vertical component combines decelerated rise with terminal-velocity fall:

swift
let posY = particle.initialPosition.y
    + (particle.initialVelocity.dy / drag) * dragFactor         // Decelerated rise
    + terminalVelocity * (t - dragFactor / drag)                // Gravity pull

The first term decelerates the initial upward velocity (negative dy). The second term adds gravitational fall — notice the (t - dragFactor/drag) form, which accounts for the time the particle spends fighting drag before transitioning to terminal descent.

Flutter and Tumble

Static particles falling in straight lines look lifeless. Two oscillation effects bring them to life:

Flutter (Horizontal Sway)

Real paper confetti catches air currents as it falls, creating a swaying motion. We simulate this with a sine wave:

swift
flutterAmplitude * sin(flutterFrequency * t + flutterPhase)

Each particle gets randomized parameters:

  • Amplitude: 25–45 points (how far it sways)
  • Frequency: 4–7 Hz (how fast it sways)
  • Phase: 0–2π (where in the cycle it starts)

The phase randomization is critical — without it, all particles would sway in sync, creating an unnatural "wave" pattern.

3D Tumble (Flip Effect)

Paper confetti doesn't just sway — it tumbles end over end, revealing its thin profile. We fake this 3D rotation by modulating the particle's apparent width:

swift
let flipAngle = particle.flipFrequency * t + particle.flipPhase
let apparentWidth = particle.size.width * max(0.15, abs(cos(flipAngle)))

When cos(flipAngle) is near ±1, the particle appears full-width (face-on). As it approaches 0, the width shrinks to 15% minimum — simulating the paper turning edge-on. The max(0.15, ...) prevents the particle from disappearing completely at the edge-on angle.

Combined with the 2D rotation from rotationSpeed, this creates a convincing illusion of paper tumbling through the air.

Rendering with Canvas and TimelineView

TimelineView drives the animation by requesting redraws on every frame. Canvas renders all particles in a single draw pass — far more efficient than creating 30 individual SwiftUI views:

swift
TimelineView(.animation) { timeline in
    Canvas { context, _ in
        let currentTime = timeline.date

        for particle in particles {
            let age = currentTime.timeIntervalSince(particle.createdAt)
            guard age < particleLifetime else { continue }

            let t = CGFloat(age)
            let dragFactor = 1 - exp(-drag * t)
            let terminalVelocity = gravity / drag

            // Calculate spread
            let spreadDirection: CGFloat =
                particle.initialVelocity.dx >= 0 ? 1 : -1
            let spreadOffset =
                spreadDirection * particle.spreadStrength * t * t

            // Position
            let posX = particle.initialPosition.x
                + (particle.initialVelocity.dx / drag) * dragFactor
                + particle.flutterAmplitude
                    * sin(particle.flutterFrequency * t + particle.flutterPhase)
                + spreadOffset
            let posY = particle.initialPosition.y
                + (particle.initialVelocity.dy / drag) * dragFactor
                + terminalVelocity * (t - dragFactor / drag)

            // Opacity fade-out
            let timeRemaining = particleLifetime - age
            let opacity = timeRemaining < fadeOutDuration
                ? timeRemaining / fadeOutDuration
                : 1.0

            // 3D flip
            let flipAngle = particle.flipFrequency * t + particle.flipPhase
            let apparentWidth = particle.size.width
                * max(0.15, abs(cos(flipAngle)))

            // Draw
            var ctx = context
            ctx.opacity = opacity
            ctx.translateBy(x: posX, y: posY)
            ctx.rotate(by: .degrees(
                particle.initialRotation + particle.rotationSpeed * age
            ))
            ctx.fill(
                Path(CGRect(
                    x: -apparentWidth / 2,
                    y: -particle.size.height / 2,
                    width: apparentWidth,
                    height: particle.size.height
                )),
                with: .color(particle.color)
            )
        }
    }
    .drawingGroup()
}

The rendering pipeline per particle is: translate to position → rotate → draw a filled rectangle with the flip-modulated width. Since Canvas uses an immediate-mode drawing API, we copy the context for each particle to isolate transforms.

The .drawingGroup() modifier is key — it tells SwiftUI to flatten the canvas into a Metal-backed texture, offloading rendering from the CPU to the GPU.

Opacity and Lifecycle

Particles that pop into and out of existence look jarring. A fade-out ramp in the final 0.4 seconds of the 1.5-second lifetime creates a smooth disappearance:

swift
private let particleLifetime: TimeInterval = 1.5
private let fadeOutDuration: TimeInterval = 0.4

// In the render loop:
let timeRemaining = particleLifetime - age
let opacity = timeRemaining < fadeOutDuration
    ? timeRemaining / fadeOutDuration  // Linear ramp from 1.0 to 0.0
    : 1.0                             // Full opacity before fade

Cleanup happens lazily via an async task that fires after the lifetime expires:

swift
.task(id: particles.isEmpty) {
    try? await Task.sleep(for: .seconds(particleLifetime + 0.1))
    removeExpiredParticles()
}

private func removeExpiredParticles() {
    let now = Date()
    particles.removeAll { particle in
        now.timeIntervalSince(particle.createdAt) >= particleLifetime
    }
}

The task(id:) modifier re-triggers whenever particles.isEmpty changes — so when a burst fires and particles go from empty to populated, the cleanup timer starts. The extra 0.1 seconds ensures all particles have fully expired before we sweep.

Spawning Particles

The createBurst function generates 30 particles with randomized parameters, positioning them across the bottom of the screen and launching them upward:

swift
private func createBurst(in size: CGSize) {
    let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple]
    let now = Date()
    var newParticles: [ConfettiParticle] = []

    for _ in 0 ..< 30 {
        // Spread across the bottom 70% of screen width
        let xSpread = CGFloat.random(in: size.width * 0.15 ... size.width * 0.85)
        let position = CGPoint(x: xSpread, y: size.height + 20)

        // Scale velocity to screen height so confetti reaches the top
        let baseUpwardSpeed = size.height * 1.8
        let upwardSpeed = -baseUpwardSpeed * CGFloat.random(in: 0.85 ... 1.1)
        let horizontalSpeed = size.width * CGFloat.random(in: -0.3 ... 0.3)

        // Three shape variants: square, wide, and tall
        let sizeType = Int.random(in: 0 ... 2)
        let particleSize = switch sizeType {
            case 0: CGSize(width: 10, height: 10)
            case 1: CGSize(width: 12, height: 7)
            default: CGSize(width: 7, height: 12)
        }

        let particle = ConfettiParticle(
            color: colors.randomElement() ?? .blue,
            size: particleSize,
            initialPosition: position,
            initialVelocity: CGVector(dx: horizontalSpeed, dy: upwardSpeed),
            initialRotation: Double.random(in: 0 ... 360),
            rotationSpeed: Double.random(in: -400 ... 400),
            createdAt: now,
            flutterAmplitude: CGFloat.random(in: 25 ... 45),
            flutterFrequency: CGFloat.random(in: 4 ... 7),
            flutterPhase: CGFloat.random(in: 0 ... .pi * 2),
            flipFrequency: CGFloat.random(in: 3 ... 6),
            flipPhase: CGFloat.random(in: 0 ... .pi * 2),
            spreadStrength: CGFloat.random(in: 8 ... 18)
        )
        newParticles.append(particle)
    }

    particles.append(contentsOf: newParticles)
}

A few design choices worth noting:

  • Screen-relative velocities: size.height * 1.8 ensures confetti reaches the top regardless of device size. On an iPhone SE, particles travel shorter distances at lower absolute speed; on an iPad, they cover more ground. The visual effect stays consistent.
  • Three shape variants: Square (10×10), landscape (12×7), and portrait (7×12) pieces create variety. Combined with the flip effect, the landscape and portrait pieces create distinctly different tumble patterns.
  • Batch creation: Building all particles in a local array and appending once avoids 30 separate state mutations.

Putting It All Together

Here's the complete ConfettiView — a self-contained, reusable component triggered by a @Binding:

swift
struct ConfettiView: View {
    @Binding var trigger: Bool

    @State private var particles: [ConfettiParticle] = []

    private let particleCount = 30
    private let particleLifetime: TimeInterval = 1.5
    private let gravity: CGFloat = 280.0
    private let drag: CGFloat = 1.6
    private let fadeOutDuration: TimeInterval = 0.4

    var body: some View {
        GeometryReader { geometry in
            Group {
                if !particles.isEmpty {
                    TimelineView(.animation) { timeline in
                        Canvas { context, _ in
                            let currentTime = timeline.date

                            for particle in particles {
                                let age = currentTime.timeIntervalSince(particle.createdAt)
                                guard age < particleLifetime else { continue }

                                let t = CGFloat(age)
                                let dragFactor = 1 - exp(-drag * t)
                                let terminalVelocity = gravity / drag
                                let spreadDirection: CGFloat =
                                    particle.initialVelocity.dx >= 0 ? 1 : -1
                                let spreadOffset =
                                    spreadDirection * particle.spreadStrength * t * t

                                let posX = particle.initialPosition.x
                                    + (particle.initialVelocity.dx / drag) * dragFactor
                                    + particle.flutterAmplitude
                                        * sin(particle.flutterFrequency * t
                                              + particle.flutterPhase)
                                    + spreadOffset
                                let posY = particle.initialPosition.y
                                    + (particle.initialVelocity.dy / drag) * dragFactor
                                    + terminalVelocity * (t - dragFactor / drag)

                                let rotation =
                                    particle.initialRotation + particle.rotationSpeed * age
                                let timeRemaining = particleLifetime - age
                                let opacity = timeRemaining < fadeOutDuration
                                    ? timeRemaining / fadeOutDuration : 1.0

                                let flipAngle =
                                    particle.flipFrequency * t + particle.flipPhase
                                let apparentWidth = particle.size.width
                                    * max(0.15, abs(cos(flipAngle)))

                                var ctx = context
                                ctx.opacity = opacity
                                ctx.translateBy(x: posX, y: posY)
                                ctx.rotate(by: .degrees(rotation))
                                ctx.fill(
                                    Path(CGRect(
                                        x: -apparentWidth / 2,
                                        y: -particle.size.height / 2,
                                        width: apparentWidth,
                                        height: particle.size.height
                                    )),
                                    with: .color(particle.color)
                                )
                            }
                        }
                        .drawingGroup()
                        .task(id: particles.isEmpty) {
                            try? await Task.sleep(for: .seconds(particleLifetime + 0.1))
                            removeExpiredParticles()
                        }
                    }
                }
            }
            .allowsHitTesting(false)
            .onChange(of: trigger) { _, shouldTrigger in
                if shouldTrigger {
                    createBurst(in: geometry.size)
                    trigger = false
                }
            }
        }
    }

    // createBurst(in:) and removeExpiredParticles() as shown above
}

Usage is straightforward — overlay it on any view and toggle the binding:

swift
struct CelebrationScreen: View {
    @State private var showConfetti = false

    var body: some View {
        ZStack {
            VStack {
                Text("Congratulations!")
                    .font(.largeTitle)

                Button("Celebrate") {
                    showConfetti = true
                }
                .buttonStyle(.borderedProminent)
            }

            ConfettiView(trigger: $showConfetti)
        }
    }
}

The binding resets itself to false inside onChange, so you can trigger multiple bursts by setting it to true again.

Performance

Three techniques keep this running smoothly at 60fps:

.drawingGroup() — Composites the Canvas into a Metal-backed offscreen buffer. Without it, each frame's draw commands go through the CPU compositing pipeline. With it, the GPU handles the heavy lifting. This is the single biggest performance win.

.allowsHitTesting(false) — Tells the hit-testing system to ignore the entire overlay. Without this, SwiftUI would run point-in-rect tests against every particle on every touch event. Since confetti is purely decorative, there's no reason to participate in hit testing.

Conditional rendering — The if !particles.isEmpty guard removes the TimelineView from the hierarchy entirely when there's no confetti. This means zero cost when idle — no frame callbacks, no Canvas allocation, no GPU texture.

WARNING

Avoid spawning more than ~100 particles simultaneously. While Canvas is efficient, each particle still requires per-frame trigonometry (sin, cos, exp). At 60fps with 100 particles, that's 6,000 trig calls per second. For larger effects, consider pre-computing lookup tables or using SpriteKit instead.

Conclusion

We built a complete confetti particle system using only SwiftUI primitives:

  • Gravity + drag create realistic deceleration and terminal velocity
  • Flutter adds organic horizontal sway via randomized sine waves
  • 3D tumble fakes paper rotation using cosine-modulated width
  • Canvas + TimelineView render all particles in a single, GPU-accelerated draw pass
  • Lifecycle management handles fade-out and cleanup without leaking memory

The physics model is intentionally simple — just enough to look convincing without the complexity of a full rigid-body simulator. From here, you could extend it with:

  • Custom shapes: Replace rectangles with Path drawings for circles, stars, or ribbons
  • Wind: Add a time-varying horizontal force to all particles
  • Trails: Render a fading history of previous positions behind each particle
  • Haptic feedback: Pair the burst with UIImpactFeedbackGenerator for a tactile response

The complete implementation is under 200 lines of Swift — small enough to understand fully, flexible enough to customize for any celebration.