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)
    }
}