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
@Bindingtoggle
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:
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.6The 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:
let dragFactor = 1 - exp(-drag * t) // Ranges from 0 to ~1Terminal velocity is the speed where gravity and drag balance perfectly:
let terminalVelocity = gravity / drag // 280 / 1.6 = 175 pts/sPhysics Summary
| Constant | Value | Effect |
|---|---|---|
| Gravity | 280 pts/s² | Downward pull |
| Drag | 1.6 | Air resistance (exponential decay) |
| Terminal velocity | 175 pts/s | Maximum fall speed |
| Lifetime | 1.5 s | How 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:
let posX = particle.initialPosition.x
+ (particle.initialVelocity.dx / drag) * dragFactor // Decelerated drift
+ particle.flutterAmplitude * sin( // Flutter sway
particle.flutterFrequency * t + particle.flutterPhase
)
+ spreadOffset // Outward driftThe 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 t² for gentle acceleration:
let spreadDirection: CGFloat = particle.initialVelocity.dx >= 0 ? 1 : -1
let spreadOffset = spreadDirection * particle.spreadStrength * t * tVertical Position
The vertical component combines decelerated rise with terminal-velocity fall:
let posY = particle.initialPosition.y
+ (particle.initialVelocity.dy / drag) * dragFactor // Decelerated rise
+ terminalVelocity * (t - dragFactor / drag) // Gravity pullThe 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:
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:
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:
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:
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 fadeCleanup happens lazily via an async task that fires after the lifetime expires:
.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:
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.8ensures 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:
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:
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
Pathdrawings 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
UIImpactFeedbackGeneratorfor a tactile response
The complete implementation is under 200 lines of Swift — small enough to understand fully, flexible enough to customize for any celebration.

