```html SwiftUI: How to Build a Card Stack (iOS 17+, 2026)

How to Build a Card Stack in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: ZStack / DragGesture Updated: May 11, 2026
TL;DR

Layer cards in a ZStack, attach a DragGesture to the top card, and remove it from the array when the drag offset crosses a threshold. A .spring() animation handles the snap-back or fly-off automatically.

struct CardStackView: View {
    @State private var cards = ["Card A", "Card B", "Card C"]
    @State private var dragOffset: CGSize = .zero

    var body: some View {
        ZStack {
            ForEach(cards.indices.reversed(), id: \.self) { index in
                Text(cards[index])
                    .frame(width: 300, height: 200)
                    .background(.blue.opacity(0.8))
                    .cornerRadius(16)
                    .offset(index == cards.indices.last
                        ? dragOffset : CGSize(width: 0,
                            height: CGFloat(cards.count - 1 - index) * -8))
                    .gesture(index == cards.indices.last
                        ? dragGesture : nil)
            }
        }
    }

    var dragGesture: some Gesture {
        DragGesture()
            .onChanged { dragOffset = $0.translation }
            .onEnded { val in
                if abs(val.translation.width) > 120 {
                    withAnimation(.spring()) { cards.removeLast() }
                }
                withAnimation(.spring()) { dragOffset = .zero }
            }
    }
}

Full implementation

The approach below uses a typed CardItem model so you can display real content. Each card in the ZStack is scaled and offset based on its distance from the top, creating a convincing depth illusion. The top card rotates slightly as it is dragged left or right, matching the direction of travel, and flies off-screen before being removed from state.

import SwiftUI

// MARK: - Model

struct CardItem: Identifiable {
    let id = UUID()
    let title: String
    let subtitle: String
    let color: Color
}

// MARK: - Single Card View

struct CardView: View {
    let card: CardItem
    let dragOffset: CGSize
    let isTop: Bool

    private var rotation: Double {
        isTop ? Double(dragOffset.width / 20) : 0
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            RoundedRectangle(cornerRadius: 8)
                .fill(card.color.opacity(0.25))
                .frame(height: 100)
                .overlay(
                    Image(systemName: "photo")
                        .font(.largeTitle)
                        .foregroundStyle(card.color)
                )
            Text(card.title)
                .font(.title2.bold())
                .foregroundStyle(.primary)
            Text(card.subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
            Spacer()
        }
        .padding(24)
        .frame(width: 320, height: 420)
        .background(.background)
        .clipShape(RoundedRectangle(cornerRadius: 24))
        .shadow(color: .black.opacity(0.12), radius: 16, x: 0, y: 6)
        .rotationEffect(.degrees(rotation))
        .accessibilityLabel("\(card.title), \(card.subtitle)")
    }
}

// MARK: - Card Stack

struct CardStackView: View {
    @State private var cards: [CardItem] = [
        CardItem(title: "Mountain Hike", subtitle: "Elevation 3,200 m", color: .green),
        CardItem(title: "City Break",    subtitle: "48-hour itinerary",  color: .orange),
        CardItem(title: "Beach Day",     subtitle: "Sunrise at 6:04 AM", color: .blue),
        CardItem(title: "Road Trip",     subtitle: "1,200 km in 3 days", color: .purple),
        CardItem(title: "Forest Walk",   subtitle: "10 km loop trail",   color: .teal),
    ]

    @State private var dragOffset: CGSize = .zero
    @State private var isDragging = false

    private let swipeThreshold: CGFloat = 130

    var body: some View {
        ZStack {
            if cards.isEmpty {
                emptyState
            } else {
                ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
                    let isTop = index == cards.count - 1
                    let depth  = cards.count - 1 - index          // 0 = top

                    CardView(
                        card: card,
                        dragOffset: isTop ? dragOffset : .zero,
                        isTop: isTop
                    )
                    .scaleEffect(isTop ? 1.0 : 1.0 - CGFloat(depth) * 0.04)
                    .offset(
                        x: isTop ? dragOffset.width : 0,
                        y: isTop ? dragOffset.height : CGFloat(depth) * -12
                    )
                    .zIndex(Double(index))
                    .gesture(isTop ? dragGesture : nil)
                    .animation(.spring(response: 0.4, dampingFraction: 0.7),
                               value: dragOffset)
                }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(.systemGroupedBackground))
    }

    // MARK: Drag gesture

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 10)
            .onChanged { value in
                isDragging = true
                dragOffset = value.translation
            }
            .onEnded { value in
                isDragging = false
                let width = value.translation.width
                if abs(width) > swipeThreshold {
                    let flyX: CGFloat = width > 0 ? 600 : -600
                    withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
                        dragOffset = CGSize(width: flyX, height: value.translation.height)
                    }
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        cards.removeLast()
                        dragOffset = .zero
                    }
                } else {
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                        dragOffset = .zero
                    }
                }
            }
    }

    // MARK: Empty state

    private var emptyState: some View {
        VStack(spacing: 16) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 64))
                .foregroundStyle(.green)
            Text("All caught up!")
                .font(.title2.bold())
            Text("You've swiped through every card.")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("All caught up. You've swiped through every card.")
    }
}

// MARK: - Preview

#Preview {
    CardStackView()
}

How it works

  1. ZStack with enumerated indexArray(cards.enumerated()) gives each card a stable position. The last element in the array is the top card. All cards are layered via .zIndex(Double(index)) so the top card renders above the rest.
  2. Depth illusion via scaleEffect and y-offset — Background cards are scaled down by 0.04 per depth level and offset upward by 12 pt × depth, so they peek out from behind the top card like a physical deck.
  3. DragGesture translation fed into offset.onChanged updates dragOffset in real time. Only the top card receives the gesture; background cards read a zero offset so they stay still during the drag.
  4. Threshold-based dismissal — In .onEnded, if abs(translation.width) > 130 the card flies off-screen first (via a large dragOffset), then is removed from the array 300 ms later after the animation completes. This avoids a jarring snap-removal.
  5. Spring snap-back for short drags — When the user releases below the threshold, dragOffset is reset to .zero inside a .spring() animation, giving satisfying elastic snap-back without any extra state.

Variants

Swipe-direction indicator (Like / Nope label)

// Inside CardView, overlay a label that fades in based on drag direction.
// Pass dragOffset and isTop from the parent.

.overlay(alignment: .topLeading) {
    if isTop {
        Text(dragOffset.width > 30 ? "KEEP" : dragOffset.width < -30 ? "SKIP" : "")
            .font(.headline.bold())
            .foregroundStyle(dragOffset.width > 0 ? .green : .red)
            .padding(10)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
            .opacity(min(abs(dragOffset.width) / 60, 1.0))
            .padding(16)
            .animation(.easeInOut(duration: 0.1), value: dragOffset)
            .accessibilityHidden(true)
    }
}

Vertical swipe to super-like

Extend the threshold check in .onEnded to also handle value.translation.height < -130 (a fast upward swipe). Assign the card a different action — e.g., append it to a savedCards array — and fly it off upward with a negative flyY offset, mirroring the horizontal dismissal logic. Keep a swipeDirection enum (.left, .right, .up) to branch on the correct action in a single .onEnded closure.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement card stack in SwiftUI for iOS 17+.
Use ZStack and DragGesture.
Make it accessible (VoiceOver labels, accessibilityAction for swipe).
Add a #Preview with realistic sample data.
Include Like/Skip overlay labels and an empty state view.

In Soarias' Build phase, drop this prompt into the active feature file — the agent will wire the card stack directly into your existing screen hierarchy and run the simulator to confirm gesture behaviour before handing back.

Related

FAQ

Does this work on iOS 16?

The ZStack / DragGesture combination works on iOS 14+. Replace the #Preview macro with PreviewProvider and avoid @Observable (use @StateObject / ObservableObject instead). Spring parameter labels like response: and dampingFraction: are available from iOS 14, so no changes needed there.

How do I make the stack refillable / infinite?

Maintain two arrays: deck (remaining cards) and discarded. In .onEnded, move the removed card into discarded instead of deleting it. When deck is empty, rotate discarded back into deck (reversing or shuffling). You can also append new cards loaded from a network call when the count drops below a threshold — the animation is identical regardless of where the data comes from.

What is the UIKit equivalent?

In UIKit you would use a UIPanGestureRecognizer attached to the top UIView in a manually managed subview stack. Update the view's transform property (combining CGAffineTransform(translationX:y:) and rotationAngle) during the gesture, then call UIView.animate(withDuration:delay:usingSpringWithDamping:…) to fly the card off or snap it back. The SwiftUI version is considerably less code and handles hit-testing automatically.

Last reviewed: 2026-05-11 by the Soarias team.

```