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

Where could it work?
Possible use cases are mostly places where you want something to feel a bit more tactile.
| Use case | How it could work |
| Product cards | Makes browsing feel more physical |
| Subscription plan cards | Gives the “premium” option a bit more wiggle |
| Album, book or game covers | Mimics picking up a real LP |
| Profile cards | Nice for dating, social or team apps, add dynamics |
| Wallet passes or tickets | Feels like a card being tilted in your hand |
| Onboarding feature cards | Makes otherwise static screens alive |
| Achievement badges | Works 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.

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
}
}
}
}