How to Build Tinder-Style Swipe Cards in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: DragGesture · rotationEffect Updated: May 12, 2026
TL;DR

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

  1. DragGesture feeds offset state. In onChanged, we write value.translation directly 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.
  2. rotationEffect creates natural tilt. Dividing offset.width by 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.
  3. LIKE / NOPE stamps are opacity-driven. likeOpacity and nopeOpacity are computed properties clamped to 0–1. They read directly from the same offset binding, so the stamps fade in proportionally as the user drags — no separate animation needed.
  4. Card stack uses ZIndex + scale. The reversed ForEach enumeration places the top card last (highest ZIndex). Back-cards are scaled down by depth * 0.035 and shifted up by depth * 12pt, giving the illusion of a physical deck without 3D transforms.
  5. Removal is driven by onChange. When topOffset.width exceeds 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

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.