confetti animation

demo

This post is about creating a confetti animation in SwiftUI with a customizable duration. I learned the technique from this Patreon tutorial.

In the original animation, the confetti pieces would change color while falling, which I considered a bug. I’ve since fixed it. Another optimization I considered was adding a fade-out effect to make the disappearance of the confetti smoother, but the result wasn’t satisfactory, so I have not included that change.

Let’s break down how to build this.

Step 1: Animate a Single Piece of Confetti

First, we’ll create the animation for a single piece of confetti. The code and the result are below:

confetti

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ConfettiView: View {
@State private var animate = false
@State private var xSpeed = Double.random(in: 0.7...2.0)
@State private var zSpeed = Double.random(in: 1.0...2.0)
@State private var anchor = CGFloat.random(in: 0...1).rounded()
@State private var color: Color = [.orange, .green, .blue, .red, .yellow].randomElement() ?? .green

var body: some View {
Rectangle()
.fill(color)
.frame(width: 14, height: 14)
.onAppear { animate = true }
.rotation3DEffect(.degrees(animate ? 360 : 0), axis: (x: 1, y: 0, z: 0))
.animation(.linear(duration: xSpeed).repeatForever(autoreverses: false), value: animate)
.rotation3DEffect(.degrees(animate ? 360 : 0),
axis: (x: 0, y: 0, z: 1),
anchor: UnitPoint(x: anchor, y: anchor))
.animation(.linear(duration: zSpeed).repeatForever(autoreverses: false), value: animate)
}
}

This line of code is the key to fixing the color-changing issue:

@State private var color: Color = [.orange, .green, .blue, .red, .yellow].randomElement() ?? .green

The color used to change because the original code would assign a new random color during the animation process. By using @State to store the color, we ensure its value is preserved for the view’s lifetime, preventing the color from changing unexpectedly.

Step 2: Create the Confetti Container

Next, we’ll create a container to hold 50 confetti pieces and manage their positioning.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ConfettiContainerView: View {
var count: Int = 50
@State private var ySeed: CGFloat = 0

var body: some View {
GeometryReader { geo in
ZStack {
ForEach(0..<count, id: \.self) { _ in
ConfettiView()
.position(
x: CGFloat.random(in: 0...geo.size.width),
y: ySeed == 0 ? 0 : CGFloat.random(in: 0...geo.size.height)
)
}
}
.ignoresSafeArea()
.onAppear {
ySeed = geo.size.height
}
}
}
}
A view filled with multiple, randomly positioned confetti pieces.

Step 3: Encapsulate as a View Modifier

Finally, to make the animation reusable and easy to apply, we’ll wrap it in a ViewModifier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
struct ConfettiModifier: ViewModifier {
@Binding var isActive: Bool
@State private var opacity = 1.0

private let animationTime = 3.0
private let fadeTime = 1.6

func body(content: Content) -> some View {
content
.overlay(
ZStack {
if isActive {
ConfettiContainerView()
.opacity(opacity)
.allowsHitTesting(false)
}
}
)
.onChange(of: isActive) { _, newValue in
guard newValue else { return }
Task {
await sequence()
}
}
}

private func sequence() async {
do {
try await Task.sleep(nanoseconds: UInt64(animationTime * 1_000_000_000))

withAnimation(.easeOut(duration: fadeTime)) {
opacity = 0
}

try await Task.sleep(nanoseconds: UInt64(fadeTime * 1_000_000_000))
isActive = false
opacity = 1.0
} catch {}
}
}

extension View {
func displayConfetti(isActive: Binding<Bool>) -> some View {
modifier(ConfettiModifier(isActive: isActive))
}
}

// Usage Example
struct ContentView: View {
@State private var showConfetti = false

var body: some View {
VStack {
Text("You did it!").font(.title.bold())
Button("Celebrate") {
showConfetti = true
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.displayConfetti(isActive: $showConfetti)
}
}