How to implement swipeable cards in SwiftUI
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
-
DragGesture tracks translation:
.onChangedwritesvalue.translation— aCGSize— directly into@State private var dragOffset. SwiftUI redraws every frame automatically because it's state-driven. -
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. -
Swipe threshold gatekeeps removal: On
.onEnded,abs(width) > thresholddecides whether to continue flying or snap back. A value of 120 pt (≈ 37% of card width) balances accidental swipes against deliberate ones. -
Stack depth uses scale + Y offset:
scale(for:)andstackOffset(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. -
Removal resets offset before the next card appears: After the fly-out animation (0.3 s),
onSwiped()removes the card from the parent array, anddragOffset = .zeroensures 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
-
⚠️ iOS 17+ spring syntax: The new
.spring(duration:bounce:)initialiser was added in iOS 17. If you need iOS 16 support, use the older.spring(response:dampingFraction:)variant instead. -
⚠️ Forget to reset dragOffset before reuse: If you restore a swiped card (e.g. an undo button), remember to reset
dragOffset = .zerobefore re-inserting it, or it will appear off-screen at its last flyout position. -
⚠️ ZStack hit-testing leaks to background cards: Background cards still receive touches through the
ZStackby default. Add.allowsHitTesting(index == cards.count - 1)to each card to restrict gestures to the topmost card only. -
⚠️ Accessibility: Swipe gestures are invisible to VoiceOver. Always pair
.accessibilityLabel+.accessibilityHintwith custom.accessibilityActionentries for "Like" and "Dismiss" so motor-impaired users can still interact with the stack.
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.