How to Build Tinder-Style Swipe Cards in SwiftUI
Track drag position with DragGesture, pipe translation.width into
rotationEffect for a natural tilt, then in onEnded either fly the card
off-screen with a spring or snap it back when the threshold isn't met.
@State private var offset: CGSize = .zero
RoundedRectangle(cornerRadius: 20)
.fill(.indigo)
.frame(width: 300, height: 420)
.rotationEffect(.degrees(Double(offset.width / 20)))
.offset(offset)
.gesture(
DragGesture()
.onChanged { offset = $0.translation }
.onEnded { v in
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
offset = abs(v.translation.width) > 120
? CGSize(width: v.translation.width > 0 ? 500 : -500, height: 0)
: .zero
}
}
)
Full implementation
The complete solution uses a Profile model array as a stack. The top card owns
live drag state; cards beneath it are scaled and offset downward in a ZStack to give depth.
LIKE and NOPE stamps appear as the user drags, their opacity tied directly to horizontal
translation so the feedback feels instant and physical. Action buttons trigger the same spring
animation programmatically for accessibility.
import SwiftUI
// MARK: - Model
struct Profile: Identifiable {
let id = UUID()
let name: String
let age: Int
let tagline: String
let color: Color
}
enum SwipeDirection { case left, right }
// MARK: - Card View
struct SwipeCardView: View {
let profile: Profile
@Binding var offset: CGSize
@Binding var direction: SwipeDirection?
private var rotation: Double { Double(offset.width / 18) }
private var likeOpacity: Double { max(0, min(1, Double(offset.width) / 80)) }
private var nopeOpacity: Double { max(0, min(1, Double(-offset.width) / 80)) }
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 24)
.fill(LinearGradient(
colors: [profile.color.opacity(0.85), profile.color],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
// Profile info
VStack(alignment: .leading, spacing: 6) {
Spacer()
Text("\(profile.name), \(profile.age)")
.font(.title.bold())
.foregroundStyle(.white)
.accessibilityAddTraits(.isHeader)
Text(profile.tagline)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.88))
}
.padding(22)
// LIKE stamp
Text("LIKE")
.font(.system(size: 34, weight: .black))
.foregroundStyle(.green)
.padding(.horizontal, 8).padding(.vertical, 4)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.green, lineWidth: 3.5))
.rotationEffect(.degrees(-18))
.padding(20)
.opacity(likeOpacity)
.accessibilityHidden(true)
// NOPE stamp
HStack {
Spacer()
Text("NOPE")
.font(.system(size: 34, weight: .black))
.foregroundStyle(.red)
.padding(.horizontal, 8).padding(.vertical, 4)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.red, lineWidth: 3.5))
.rotationEffect(.degrees(18))
.padding(20)
.opacity(nopeOpacity)
.accessibilityHidden(true)
}
}
.frame(width: 320, height: 480)
.rotationEffect(.degrees(rotation))
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
direction = value.translation.width > 0 ? .right : .left
}
.onEnded { value in
handleEnd(translation: value.translation)
}
)
.animation(.interactiveSpring(response: 0.25, dampingFraction: 0.72), value: offset)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(profile.name), age \(profile.age). \(profile.tagline). Swipe right to like, left to pass.")
}
private func handleEnd(translation: CGSize) {
withAnimation(.spring(response: 0.42, dampingFraction: 0.74)) {
if translation.width > 120 {
offset = CGSize(width: 620, height: translation.height)
} else if translation.width < -120 {
offset = CGSize(width: -620, height: translation.height)
} else {
offset = .zero
direction = nil
}
}
}
}
// MARK: - Card Stack
struct TinderStackView: View {
@State private var profiles: [Profile] = [
Profile(name: "Alex", age: 28, tagline: "Coffee enthusiast & trail runner", color: .indigo),
Profile(name: "Jordan", age: 25, tagline: "Chef by day, jazz fan by night", color: .purple),
Profile(name: "Morgan", age: 30, tagline: "Architect who loves to travel", color: .teal),
Profile(name: "Casey", age: 27, tagline: "Dog mum & yoga instructor", color: .pink),
Profile(name: "Riley", age: 26, tagline: "Photographer chasing golden hour", color: .orange),
]
@State private var topOffset: CGSize = .zero
@State private var swipeDirection: SwipeDirection?
var body: some View {
VStack(spacing: 36) {
ZStack {
if profiles.isEmpty {
ContentUnavailableView(
"All caught up",
systemImage: "heart.slash",
description: Text("No more profiles for now.")
)
} else {
ForEach(Array(profiles.prefix(3).reversed().enumerated()), id: \.element.id) { index, profile in
let stackCount = min(profiles.count, 3)
let isTop = index == stackCount - 1
let depth = CGFloat(stackCount - 1 - index)
SwipeCardView(
profile: profile,
offset: isTop ? $topOffset : .constant(.zero),
direction: isTop ? $swipeDirection : .constant(nil)
)
.scaleEffect(1.0 - depth * 0.035)
.offset(y: -depth * 12)
.zIndex(Double(index))
.onChange(of: topOffset) { _, newOffset in
if abs(newOffset.width) > 500 { removeTop() }
}
}
}
}
.frame(height: 520)
// Buttons
HStack(spacing: 44) {
CircleButton(icon: "xmark", tint: .red) { trigger(.left) }
.accessibilityLabel("Pass")
CircleButton(icon: "heart.fill", tint: .green) { trigger(.right) }
.accessibilityLabel("Like")
}
.opacity(profiles.isEmpty ? 0 : 1)
}
.padding()
}
private func trigger(_ dir: SwipeDirection) {
let x: CGFloat = dir == .right ? 620 : -620
withAnimation(.spring(response: 0.42, dampingFraction: 0.74)) {
topOffset = CGSize(width: x, height: 0)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { removeTop() }
}
private func removeTop() {
guard !profiles.isEmpty else { return }
profiles.removeFirst()
topOffset = .zero
swipeDirection = nil
}
}
// MARK: - Helper
struct CircleButton: View {
let icon: String
let tint: Color
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.title2.bold())
.foregroundStyle(tint)
.frame(width: 64, height: 64)
.background(tint.opacity(0.1))
.clipShape(Circle())
}
}
}
#Preview {
TinderStackView()
}
How it works
-
DragGesture feeds offset state. In
onChanged, we writevalue.translationdirectly into@State var topOffset: CGSize. Because SwiftUI re-renders on every state mutation, the card tracks the finger with zero lag without any manual frame calculations. -
rotationEffect creates natural tilt. Dividing
offset.widthby 18 maps the full screen width (~390 pt) to roughly ±22 degrees — enough to feel physical without looking broken. Cards behind the finger pivot from their center by default, which matches the mental model of picking up a card. -
LIKE / NOPE stamps are opacity-driven.
likeOpacityandnopeOpacityare computed properties clamped to 0–1. They read directly from the sameoffsetbinding, so the stamps fade in proportionally as the user drags — no separate animation needed. -
Card stack uses ZIndex + scale. The reversed
ForEachenumeration places the top card last (highest ZIndex). Back-cards are scaled down bydepth * 0.035and shifted up bydepth * 12pt, giving the illusion of a physical deck without 3D transforms. -
Removal is driven by onChange. When
topOffset.widthexceeds 500 pt (off-screen),removeTop()pops the first item from the array and resets offset to.zero. The next card in the ZStack naturally becomes the new top.
Variants
Vertical swipe-up for "Super Like"
Add a vertical threshold check alongside the horizontal one to handle a dedicated upward swipe gesture. Colour-code the stamp gold and fire a haptic for feedback.
// Inside handleEnd(translation:)
if translation.height < -160 && abs(translation.width) < 60 {
// Super Like — fly straight up
withAnimation(.spring(response: 0.42, dampingFraction: 0.74)) {
offset = CGSize(width: 0, height: -900)
}
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
return
}
// Inside SwipeCardView body — add Super Like stamp
let superOpacity = max(0, min(1, Double(-offset.height) / 80))
Text("SUPER")
.font(.system(size: 34, weight: .black))
.foregroundStyle(.yellow)
.padding(.horizontal, 8).padding(.vertical, 4)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.yellow, lineWidth: 3.5))
.frame(maxWidth: .infinity)
.padding(.top, 28)
.opacity(superOpacity)
.accessibilityHidden(true)
Matched! overlay with matched animation
After a right-swipe removal, set a @State var showMatch = true flag and display
a full-screen ZStack overlay with a scale-in heart using
.transition(.scale.combined(with: .opacity)) and
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)).
Dismiss automatically after 2 seconds with a Task { try? await Task.sleep(for: .seconds(2)) }
block on appearance.
Common pitfalls
-
iOS 16 gesture conflict.
DragGestureinside aScrollViewon iOS 16 steals the gesture. On iOS 17+ use.simultaneousGestureor wrap the stack in a plainVStackwith no scroll container to avoid conflict with the system pan gesture recognizer. -
Animation fighting itself. Applying both
.animation(.interactiveSpring, value: offset)on the card and wrapping the state mutation inwithAnimationinonEndedproduces double-spring artifacts. Use the view modifier for live drag tracking andwithAnimationonly in the end handler — never both. -
Accessibility blind spot. Cards relying solely on visual stamps are
unusable by VoiceOver users. Always attach
.accessibilityLabelwith the swipe instruction text and mark stamp overlays as.accessibilityHidden(true). The CircleButton Like/Pass controls must also have explicit labels so motor-impaired users can activate swipes without gestures.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement tinder-style swipe cards in SwiftUI for iOS 17+. Use DragGesture and rotationEffect. Build a card stack: top card accepts drag, back cards scale/offset for depth. Show LIKE / NOPE stamps that fade in proportionally to drag distance. Add Like and Pass buttons that animate programmatically. Make it accessible (VoiceOver labels on cards and buttons). Add a #Preview with realistic sample data (5 profiles, varied colours).
Drop this prompt into the Soarias Build phase after you've scaffolded your screen list — the
generated TinderStackView slots cleanly into any discovery or matching flow
without additional wiring.
Related
FAQ
Does this work on iOS 16?
Mostly yes — DragGesture and rotationEffect are available since
iOS 13. However, the #Preview macro and ContentUnavailableView
require iOS 17. Swap those out for PreviewProvider and a plain
Text placeholder respectively if you must target iOS 16.
How do I limit the drag to horizontal only (no vertical card movement)?
In onChanged, zero out the vertical component:
offset = CGSize(width: value.translation.width, height: value.translation.width / 8).
The small divided vertical term adds a subtle arc that looks natural while keeping
the card mostly horizontal. For strict lock, use height: 0.
What's the UIKit equivalent?
In UIKit you'd use a UIPanGestureRecognizer on each card view,
apply a CGAffineTransform rotation + translation in the
.changed handler, and UIView.animate(withSpring…)
in .ended. The SwiftUI approach is roughly 60 % less code and eliminates
manual view hierarchy management.
Last reviewed: 2026-05-12 by the Soarias team.