This gives a flat SwiftUI card a fake 3D feel by rotating it based on your drag gesture. It is not real motion tracking, but it feels similar enough for buttons, cards, covers or premium-looking UI bits. When you let go, the spring animation snaps it neatly back into place.

Snippet

import SwiftUI

struct CardReveal: View {
    @State private var drag = CGSize.zero

    var body: some View {
        ZStack {
            LinearGradient(
                colors: [.black, .indigo],
                startPoint: .top,
                endPoint: .bottom
            )
            .ignoresSafeArea()

            RoundedRectangle(cornerRadius: 32)
                .fill(.purple.gradient)
                .frame(width: 260, height: 360)
                .overlay {
                    VStack {
                        Text("SwiftUI")
                            .font(.largeTitle.bold())

                        Text("Drag me")
                            .foregroundStyle(.secondary)
                    }
                    .foregroundStyle(.white)
                }
                .rotation3DEffect(
                    .degrees(Double(drag.width / 12)),
                    axis: (x: 0, y: 1, z: 0)
                )
                .rotation3DEffect(
                    .degrees(Double(-drag.height / 12)),
                    axis: (x: 1, y: 0, z: 0)
                )
                .shadow(radius: 30)
                .gesture(
                    DragGesture()
                        .onChanged { drag = $0.translation }
                        .onEnded { _ in
                            withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
                                drag = .zero
                            }
                        }
                )
        }
    }
}

Preview

Simulator Screen Recording - iPhone 17 Pro - 2026-05-22 at 18.30.29.gif

Where could it work?

Possible use cases are mostly places where you want something to feel a bit more tactile.

Use caseHow it could work
Product cardsMakes browsing feel more physical
Subscription plan cardsGives the “premium” option a bit more wiggle
Album, book or game coversMimics picking up a real LP
Profile cardsNice for dating, social or team apps, add dynamics
Wallet passes or ticketsFeels like a card being tilted in your hand
Onboarding feature cardsMakes otherwise static screens alive
Achievement badgesWorks nicely when something is meant to feel collectible

Further Tweaks

Will a little bit of tweaking (advanced layout, haptic feedback and computed properties) we can create a posh looking product preview.

Simulator Screen Recording - iPhone 17 Pro - 2026-05-22 at 20.23.02.gif

Here’s the self-documenting code for this view, should you want to use this as a recipe to start with.

import SwiftUI

struct CardReveal_Product: View {
    @State private var drag = CGSize.zero

    // Keeps a tiny fake basket state for the demo.
    // In a real shop this would probably come from some basket or checkout model.
    @State private var basketCount = 0

    // Used only to make the basket icon pop when something is added.
    @State private var showBasketPulse = false

    // Toggling this triggers haptic feedback.
    // SwiftUI watches this value through sensoryFeedback below.
    @State private var didAdd = false

    // We use the drag direction to swap the product image.
    // It makes the card feel like you are inspecting the item, not just wobbling a rectangle.
    private var productImageName: String {
        if drag.height < -60 {
            return "top"
        }

        if abs(drag.width) > 70 {
            return "back"
        }

        return "front"
    }

    // Tiny helper label so the interaction is obvious in a screen recording.
    // Without this, people may miss what is changing.
    private var viewLabel: String {
        if drag.height < -60 {
            return "Top view"
        }

        if abs(drag.width) > 70 {
            return "Back view"
        }

        return "Front view"
    }

    var body: some View {
        ZStack {
            // Adding fancy upmarket gradient here
            LinearGradient(
                colors: [.white, .black.opacity(0.02)],
                startPoint: .top,
                endPoint: .bottom
            )
            .ignoresSafeArea()
            
            VStack(spacing: 18) {
                // Only the product card tilts.
                // The Liquid Glass controls sit outside this layer so they do not get warped.
                productCard
                    .padding(.vertical, 40)
                    .rotation3DEffect(
                        .degrees(Double(drag.width / 18)),
                        axis: (x: 0, y: 1, z: 0)
                    )
                    .rotation3DEffect(
                        .degrees(Double(-drag.height / 18)),
                        axis: (x: 1, y: 0, z: 0)
                    )
                    .shadow(radius: 30)
                    .gesture(cardDragGesture)
                
                // Floating over the card rather than being part of the tilted card.
                // This avoids the Liquid Glass stretching issue during 3D rotation.
                addToBasketButton
                
                floatingBasketButton
            }
            
            // Native haptic feedback.
            // It fires whenever didAdd changes.
            .sensoryFeedback(.success, trigger: didAdd)
            
            // Smooths the badge appearing and changing.
            .animation(.spring(response: 0.28, dampingFraction: 0.7), value: basketCount)
        }
    }

    private var productCard: some View {
        RoundedRectangle(cornerRadius: 32)
            .fill(.white)
            .frame(width: 320, height: 460)
            .overlay {
                VStack(spacing: 10) {
                    ZStack {
                        Image(productImageName)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: 230, height: 230)
                            .cornerRadius(25)

                            // This forces SwiftUI to treat each product angle as a different view.
                            // Otherwise the image may update in place and the transition will not really happen.
                            .id(productImageName)

                            // Small fade and scale transition.
                            // It feels nicer than just snapping from front to back.
                            .transition(
                                .asymmetric(
                                    insertion: .opacity.combined(with: .scale(scale: 0.96)),
                                    removal: .opacity.combined(with: .scale(scale: 1.04))
                                )
                            )
                    }

                    // Fixed space stops the product text from jumping if image ratios differ.
                    .frame(height: 250)
                    .animation(.easeInOut(duration: 0.22), value: productImageName)

                    Text(viewLabel.uppercased())
                        .font(.caption.bold())
                        .foregroundStyle(.secondary)
                        .animation(.easeInOut(duration: 0.2), value: viewLabel)

                    Text("£159")
                        .font(.title.bold())

                    Text("BARBOUR")
                        .font(.largeTitle.bold())

                    Text("Transport Foldover Backpack")
                        .font(.subheadline.bold())
                        .foregroundStyle(.secondary)
                }
                .foregroundStyle(.black)
            }
    }

    private var addToBasketButton: some View {
        Button {
            addToBasket()
        } label: {
            Label("Add to basket", systemImage: "bag.badge.plus")
                .font(.headline)
                .padding(.horizontal, 20)
                .padding(.vertical, 12)
        }
        .buttonStyle(.plain)

        // Liquid Glass is happiest when it is not being bent by rotation3DEffect.
        .glassEffect(.regular.interactive(), in: .capsule)
    }

    private var floatingBasketButton: some View {
        VStack {
            Spacer()

            HStack {
                Spacer()

                Button {
                    // In a real app this would open the basket.
                    // For this snippet it just gives us a small feedback moment.
                    didAdd.toggle()
                } label: {
                    ZStack(alignment: .topTrailing) {
                        Image(systemName: "bag")
                            .font(.title2.bold())
                            .frame(width: 56, height: 56)

                        if basketCount > 0 {
                            Text("\(basketCount)")
                                .font(.caption2.bold())
                                .foregroundStyle(.white)
                                .frame(width: 20, height: 20)
                                .background(.black)
                                .clipShape(Circle())
                                .offset(x: 4, y: -4)
                                .transition(.scale.combined(with: .opacity))
                        }
                    }

                    // This is the small pop after adding an item.
                    .scaleEffect(showBasketPulse ? 1.18 : 1)
                }
                .buttonStyle(.plain)

                // Floating Liquid Glass basket button.
                // This gives the demo a more complete shopping UI shape.
                .glassEffect(.regular.interactive(), in: .circle)
                .padding(.trailing, 24)
                .padding(.bottom, 24)
            }
        }
    }

    private var cardDragGesture: some Gesture {
        DragGesture()
            .onChanged { value in
                // Animating the drag update makes the image change feel less twitchy.
                withAnimation(.easeInOut(duration: 0.18)) {
                    drag = value.translation
                }
            }
            .onEnded { _ in
                // Springing back to zero gives the card that nice "put back down" feel.
                withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
                    drag = .zero
                }
            }
    }

    private func addToBasket() {
        // Add one item to the fake basket.
        basketCount += 1

        // Trigger a small haptic.
        didAdd.toggle()

        // Make the floating basket icon pop a little.
        withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) {
            showBasketPulse = true
        }

        // Then let it settle back down.
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
            withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) {
                showBasketPulse = false
            }
        }
    }
}

TAGS

SHARE VIA

RELATED POSTS