How to Build a Card Stack in SwiftUI
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
- ZStack with enumerated index —
Array(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. - Depth illusion via scaleEffect and y-offset — Background cards are scaled down by
0.04per depth level and offset upward by12 pt × depth, so they peek out from behind the top card like a physical deck. - DragGesture translation fed into offset —
.onChangedupdatesdragOffsetin real time. Only the top card receives the gesture; background cards read a zero offset so they stay still during the drag. - Threshold-based dismissal — In
.onEnded, ifabs(translation.width) > 130the card flies off-screen first (via a largedragOffset), then is removed from the array 300 ms later after the animation completes. This avoids a jarring snap-removal. - Spring snap-back for short drags — When the user releases below the threshold,
dragOffsetis reset to.zeroinside 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
- iOS 16 compatibility: The
#Previewmacro requires Xcode 15+ / iOS 17+. If you target iOS 16 you must replace it withstruct CardStackView_Previews: PreviewProvider. The gesture and ZStack code itself runs on iOS 16, but you lose@Observableand some spring parameter labels differ. - Gesture conflict with ScrollView: Embedding a card stack inside a
ScrollViewcauses the horizontalDragGestureto compete with vertical scroll. UseDragGesture(minimumDistance: 10, coordinateSpace: .global)and set.simultaneousGestureor add a.scrollDisabled(true)flag on the scroll container when a card drag starts. - Removal before animation completes: Calling
cards.removeLast()inside the samewithAnimationblock as the fly-off offset causes the card to vanish before it reaches the edge. Always defer removal withDispatchQueue.main.asyncAfter— matching the animation duration — or use a.onDisappearmodifier keyed to the card's ID.
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.