What You Will Build

A stacked deck of cards where the front card can be swiped away and returns like a boomerang. Cards behind the front card are progressively smaller and offset to create a visual depth effect. A rotation toggle lets cards fan slightly for a more dynamic look. This pattern is used in dating apps like Tinder, flashcard apps, and decision-making interfaces.

Why This Pattern Matters

Card stacks are one of the most recognizable mobile interaction patterns. The key challenges are managing z-index ordering, creating the depth illusion with scale and offset, and making the drag feel natural. This implementation teaches you how to coordinate multiple animated properties across an array of data items.

Key SwiftUI Concepts

  • Dynamic zIndex — controls which card renders on top during animation.
  • Index-based sizing — each card shrinks by a fixed amount based on its position in the array.
  • Conditional rotation — cards behind the front can fan at different angles.

Step 1: Define the Card Model

struct BoomerangCard: Identifiable {
    var id = UUID()
    var title: String
    var color: Color
    var isRotated: Bool = false
    var extraOffset: CGFloat = 0
    var scale: CGFloat = 1
    var zIndex: Double = 0
}

Step 2: Build the Card Stack View

Each card is sized based on its index in the array. The first card (index 0) is the largest, and each subsequent card shrinks by 20pt in width and 10pt in height, creating a cascading depth effect:

struct BoomerangCardStack: View {
    @Binding var cards: [BoomerangCard]
    var isRotationEnabled: Bool

    var body: some View {
        GeometryReader {
            let size = $0.size
            ZStack {
                ForEach(cards) { card in
                    BoomerangCardView(card: card, size: size)
                        .zIndex(card.zIndex)
                }
            }
            .frame(width: size.width, height: size.height)
        }
    }

    @ViewBuilder
    func BoomerangCardView(card: BoomerangCard, size: CGSize) -> some View {
        let index = indexOf(card)
        RoundedRectangle(cornerRadius: 15)
            .fill(card.color.gradient)
            .frame(
                width: size.width - CGFloat(index) * 20,
                height: size.height - CGFloat(index) * 10
            )
            .overlay {
                Text(card.title)
                    .font(.title.bold())
                    .foregroundStyle(.white)
            }
            .offset(y: CGFloat(index) * -10)
            .scaleEffect(card.scale)
            .offset(x: card.extraOffset)
            .rotationEffect(.degrees(
                card.isRotated
                    ? (index == 1 ? -5 : (index == 2 ? 8 : 0))
                    : 0
            ))
    }

    func indexOf(_ card: BoomerangCard) -> Int {
        cards.firstIndex(where: { $0.id == card.id }) ?? 0
    }
}

Step 3: Wire It Up with Controls

struct BoomerangCardsDemo: View {
    @State var cards: [BoomerangCard] = [
        .init(title: "Design", color: .red),
        .init(title: "Code", color: .blue),
        .init(title: "Build", color: .green),
        .init(title: "Ship", color: .purple),
        .init(title: "Grow", color: .orange),
    ]
    @State var isRotationEnabled: Bool = true

    var body: some View {
        VStack(spacing: 20) {
            Toggle("Enable Rotation", isOn: $isRotationEnabled)
                .padding(.horizontal)

            BoomerangCardStack(
                cards: $cards,
                isRotationEnabled: isRotationEnabled
            )
            .frame(height: 300)
            .padding()
        }
    }
}

Tips and Pitfalls

  • zIndex is critical: Without explicit zIndex values, SwiftUI renders cards in ForEach order. When animating a card to the back of the stack, you must update its zIndex or it will visually overlap cards it should be behind.
  • Scale vs size: Using .scaleEffect for the active card animation and frame width/height for the static depth effect keeps the two concerns separate.
  • Rotation angles: The hardcoded -5 and 8 degree rotations for index 1 and 2 create an asymmetric fan. Symmetric angles look artificial.
  • .gradient modifier: Adding .gradient to a Color gives each card a subtle gradient without defining custom gradient stops.
  • Performance: With 5-10 cards, this approach performs well. For larger decks, only render the top 3-4 cards and recycle views.

iOS Version: iOS 16+ (for .gradient modifier)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.