```html SwiftUI: How to Implement Swipeable Cards (iOS 17+, 2026)

How to implement swipeable cards in SwiftUI

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

Attach a DragGesture to a card view, mirror its translation as an .offset and .rotationEffect, then on gesture end either fling the card off-screen with withAnimation(.easeOut) or snap it back with a spring if the swipe didn't reach the threshold.

RoundedRectangle(cornerRadius: 24)
    .fill(.indigo.gradient)
    .frame(width: 320, height: 460)
    .offset(dragOffset)
    .rotationEffect(.degrees(Double(dragOffset.width / 24)), anchor: .bottom)
    .gesture(
        DragGesture()
            .onChanged { dragOffset = $0.translation }
            .onEnded { value in
                let past = abs(value.translation.width) > 120
                withAnimation(past ? .easeOut(duration: 0.3) : .spring(duration: 0.4)) {
                    dragOffset = past
                        ? CGSize(width: value.translation.width > 0 ? 600 : -600,
                                 height: value.translation.height)
                        : .zero
                }
            }
    )

Full implementation

The architecture splits into two views: CardStackView owns the array of cards and renders them in a ZStack with depth-scaling, and SwipeCard handles the gesture, offset, rotation, and removal callback independently. This keeps gesture state local and avoids unnecessary parent re-renders on every drag tick. The stack uses a reverse ForEach so the topmost card (highest index) sits in front and handles touches first.

import SwiftUI

// MARK: - Model

struct Card: Identifiable {
    let id = UUID()
    let name: String
    let color: Color
}

// MARK: - Stack

struct CardStackView: View {
    @State private var cards: [Card] = [
        Card(name: "Mountain Hike",   color: .indigo),
        Card(name: "Ocean Sunset",    color: .teal),
        Card(name: "City Lights",     color: .orange),
        Card(name: "Forest Trail",    color: .green),
        Card(name: "Desert Dunes",    color: .brown),
    ]

    var body: some View {
        ZStack {
            ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
                SwipeCard(card: card) {
                    withAnimation(.spring(duration: 0.35)) {
                        cards.removeAll { $0.id == card.id }
                    }
                }
                .zIndex(Double(index))
                .scaleEffect(scale(for: index))
                .offset(y: stackOffset(for: index))
                .animation(.spring(duration: 0.35), value: cards.count)
            }
        }
        .frame(width: 320, height: 500)
    }

    private func scale(for index: Int) -> CGFloat {
        let top = cards.count - 1
        return max(1.0 - CGFloat(top - index) * 0.04, 0.88)
    }

    private func stackOffset(for index: Int) -> CGFloat {
        let top = cards.count - 1
        return CGFloat(top - index) * -12
    }
}

// MARK: - Individual Card

struct SwipeCard: View {
    let card: Card
    let onSwiped: () -> Void

    @State private var dragOffset: CGSize = .zero
    private let threshold: CGFloat = 120

    private var swipeProgress: CGFloat {
        min(abs(dragOffset.width) / threshold, 1.0)
    }

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 24)
                .fill(card.color.gradient)
                .frame(width: 320, height: 460)
                .shadow(color: .black.opacity(0.15), radius: 12, y: 6)

            VStack(spacing: 16) {
                RoundedRectangle(cornerRadius: 12)
                    .fill(.white.opacity(0.25))
                    .frame(width: 240, height: 160)

                Text(card.name)
                    .font(.title2.bold())
                    .foregroundStyle(.white)

                Text("Swipe left or right")
                    .font(.subheadline)
                    .foregroundStyle(.white.opacity(0.7))
            }

            // Swipe direction indicators
            HStack {
                Label("Nope", systemImage: "xmark.circle.fill")
                    .foregroundStyle(.red)
                    .font(.title3.bold())
                    .opacity(dragOffset.width < -20 ? Double(-dragOffset.width / threshold) : 0)
                Spacer()
                Label("Like", systemImage: "heart.circle.fill")
                    .foregroundStyle(.green)
                    .font(.title3.bold())
                    .opacity(dragOffset.width > 20 ? Double(dragOffset.width / threshold) : 0)
            }
            .padding(.horizontal, 24)
            .frame(maxHeight: .infinity, alignment: .top)
            .padding(.top, 24)
        }
        .offset(dragOffset)
        .rotationEffect(
            .degrees(Double(dragOffset.width / 24)),
            anchor: .bottom
        )
        .gesture(
            DragGesture()
                .onChanged { value in
                    dragOffset = value.translation
                }
                .onEnded { value in
                    let width = value.translation.width
                    if abs(width) > threshold {
                        let direction: CGFloat = width > 0 ? 1 : -1
                        withAnimation(.easeOut(duration: 0.3)) {
                            dragOffset = CGSize(
                                width: direction * 700,
                                height: value.translation.height
                            )
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            onSwiped()
                            dragOffset = .zero
                        }
                    } else {
                        withAnimation(.spring(duration: 0.45, bounce: 0.4)) {
                            dragOffset = .zero
                        }
                    }
                }
        )
        .accessibilityLabel("\(card.name), swipeable card")
        .accessibilityHint("Swipe left to dismiss or right to like")
    }
}

// MARK: - Preview

#Preview {
    CardStackView()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(.systemGroupedBackground))
}

How it works

  1. DragGesture tracks translation: .onChanged writes value.translation — a CGSize — directly into @State private var dragOffset. SwiftUI redraws every frame automatically because it's state-driven.
  2. Rotation simulates a physical card: .rotationEffect(.degrees(dragOffset.width / 24), anchor: .bottom) pivots around the bottom edge, so the card tilts as if held between two fingers — the divisor (24) controls sensitivity.
  3. Swipe threshold gatekeeps removal: On .onEnded, abs(width) > threshold decides whether to continue flying or snap back. A value of 120 pt (≈ 37% of card width) balances accidental swipes against deliberate ones.
  4. Stack depth uses scale + Y offset: scale(for:) and stackOffset(for:) reduce each background card's scale by 4% and nudge it up by 12 pt per position — creating a convincing fan without a custom layout.
  5. Removal resets offset before the next card appears: After the fly-out animation (0.3 s), onSwiped() removes the card from the parent array, and dragOffset = .zero ensures the freshly promoted top card starts centered.

Variants

Vertical swipe-to-skip (top/bottom dismissal)

// In .onEnded, check vertical translation too:
.onEnded { value in
    let h = value.translation.height
    let w = value.translation.width
    let swipedUp    = h < -threshold
    let swipedSide  = abs(w) > threshold

    if swipedUp || swipedSide {
        let targetX: CGFloat = swipedSide ? (w > 0 ? 700 : -700) : 0
        let targetY: CGFloat = swipedUp   ? -900 : value.translation.height
        withAnimation(.easeOut(duration: 0.3)) {
            dragOffset = CGSize(width: targetX, height: targetY)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            onSwiped()
            dragOffset = .zero
        }
    } else {
        withAnimation(.spring(duration: 0.45, bounce: 0.4)) {
            dragOffset = .zero
        }
    }
}

Haptic feedback on swipe commit

Add let impact = UIImpactFeedbackGenerator(style: .medium) as a property, then call impact.impactOccurred() inside the abs(width) > threshold branch of .onEnded. For a subtler "rubber band" feel as the user approaches the threshold, fire UISelectionFeedbackGenerator().selectionChanged() inside .onChange(of: swipeProgress) when swipeProgress crosses 1.0.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement swipeable cards in SwiftUI for iOS 17+.
Use DragGesture and Animation (spring + easeOut).
Build a CardStackView with depth scaling and a SwipeCard
component that removes itself from the parent array on swipe.
Make it accessible (VoiceOver labels + accessibilityAction).
Add a #Preview with realistic sample data (5 cards).

In Soarias, drop this prompt into the Build phase after your mockup screens are locked — Claude Code will scaffold both files, wire up the model, and generate the preview in a single pass.

Related

FAQ

Does this work on iOS 16?

Mostly yes — DragGesture and withAnimation are available on iOS 16. However, the .spring(duration:bounce:) modifier requires iOS 17+. For iOS 16 compatibility, replace it with .spring(response: 0.45, dampingFraction: 0.7) and remove the #Preview macro (use PreviewProvider instead).

How do I know which direction the user swiped — left or right — to take different actions?

Inside .onEnded, check the sign of value.translation.width: positive means right (like/accept), negative means left (dismiss/reject). Pass a direction enum — e.g. enum SwipeDirection { case left, right } — into the onSwiped closure so the parent can update separate "liked" and "skipped" arrays accordingly.

What is the UIKit equivalent?

In UIKit you'd attach a UIPanGestureRecognizer to a UIView and mutate its transform property via CGAffineTransform(translationX:y:) combined with rotated(by:). On gesture end, use UIView.animate(withDuration:...) to fly it off or spring it back. The SwiftUI version is considerably less code and the state binding replaces all the manual transform bookkeeping.

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

```