How to Build 3D Rotation in SwiftUI
Apply .rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0)) to any view and drive
angle with @State to spin it in three dimensions.
Wrap state changes in withAnimation for smooth, spring-driven 3D motion.
struct FlipButton: View {
@State private var angle: Double = 0
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 120, height: 80)
.rotation3DEffect(
.degrees(angle),
axis: (x: 0, y: 1, z: 0)
)
.onTapGesture {
withAnimation(.spring(duration: 0.5)) {
angle += 180
}
}
}
}
Full implementation
The classic showcase for rotation3DEffect is a flip card: a view that shows a front face,
then rotates 180° around the Y axis to reveal a back face. The trick is to keep both faces in the view
hierarchy, hide the back face until the rotation passes 90°, and apply a counter-rotation to the back
face so its text isn't mirrored. A single @State var flipped: Bool drives the whole animation.
import SwiftUI
// MARK: - Flip Card
struct FlipCard: View {
let front: AnyView
let back: AnyView
@State private var flipped = false
private var rotation: Double { flipped ? 180 : 0 }
var body: some View {
ZStack {
// Back face — visible after 90°
back
.opacity(flipped ? 1 : 0)
.rotation3DEffect(
.degrees(rotation - 180), // counter-rotate so text reads correctly
axis: (x: 0, y: 1, z: 0)
)
// Front face — hidden after 90°
front
.opacity(flipped ? 0 : 1)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0)
)
}
.frame(width: 280, height: 180)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.15)) {
flipped.toggle()
}
}
.accessibilityLabel(flipped ? "Card back. Tap to flip." : "Card front. Tap to flip.")
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Face views
private struct FrontFace: View {
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.overlay(
VStack(spacing: 8) {
Image(systemName: "creditcard.fill")
.font(.system(size: 36))
.foregroundStyle(.white)
Text("Tap to flip")
.font(.headline)
.foregroundStyle(.white.opacity(0.9))
}
)
}
}
private struct BackFace: View {
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.indigo.gradient)
.overlay(
VStack(spacing: 8) {
Image(systemName: "star.fill")
.font(.system(size: 36))
.foregroundStyle(.yellow)
Text("Back side!")
.font(.headline)
.foregroundStyle(.white.opacity(0.9))
}
)
}
}
// MARK: - Demo screen
struct RotationDemoView: View {
var body: some View {
VStack(spacing: 40) {
Text("3D Flip Card")
.font(.title2.bold())
FlipCard(
front: AnyView(FrontFace()),
back: AnyView(BackFace())
)
Text("Tap the card to flip it")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
}
#Preview {
RotationDemoView()
}
How it works
-
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))— Theaxistuple picks the direction of spin. Settingy: 1while keepingxandzat 0 produces a horizontal-card flip. Swapyforxto flip top-to-bottom, orzto spin like a wheel. -
Counter-rotation on the back face — The back face is rotated by
rotation - 180, which keeps its contents readable rather than mirror-reversed as the card turns past 90°. -
Opacity crossfade at 90° — Setting
.opacity(flipped ? 1 : 0)and.opacity(flipped ? 0 : 1)swaps visibility the momentflippedtoggles. SwiftUI's animation system interpolates these together with the rotation so the switch looks seamless. -
withAnimation(.spring(duration: 0.6, bounce: 0.15))— A spring animation gives the flip a satisfying overshoot. Increasingbounceadds more wobble; set it to0for a crisp, linear flip. -
Accessibility — Because the visual flip communicates state, the
accessibilityLabeldescribes which face is visible and tells VoiceOver users how to interact with it, without relying on the animation itself.
Variants
Continuous Y-axis spin on appear
struct SpinningIcon: View {
@State private var angle: Double = 0
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
.rotation3DEffect(
.degrees(angle),
axis: (x: 0, y: 1, z: 0),
perspective: 0.4 // stronger depth distortion
)
.onAppear {
withAnimation(
.linear(duration: 2.5)
.repeatForever(autoreverses: false)
) {
angle = 360
}
}
.accessibilityHidden(true) // decorative spinner
}
}
#Preview { SpinningIcon() }
Gesture-driven tilt (drag to rotate)
Combine rotation3DEffect with a DragGesture to let users tilt a card
interactively. Map translation.width to the Y axis and
translation.height to the X axis, clamped to ±45°, for a tilt-to-inspect effect popular
in trading-card and product apps:
struct TiltCard: View {
@State private var dragOffset: CGSize = .zero
private var tiltX: Double { Double(-dragOffset.height / 6) }
private var tiltY: Double { Double(dragOffset.width / 6) }
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.teal.gradient)
.frame(width: 260, height: 160)
.rotation3DEffect(.degrees(tiltX), axis: (x: 1, y: 0, z: 0), perspective: 0.5)
.rotation3DEffect(.degrees(tiltY), axis: (x: 0, y: 1, z: 0), perspective: 0.5)
.gesture(
DragGesture()
.onChanged { dragOffset = $0.translation }
.onEnded { _ in
withAnimation(.spring) { dragOffset = .zero }
}
)
.accessibilityLabel("Tiltable card. Drag to tilt.")
}
}
#Preview { TiltCard().padding() }
Common pitfalls
-
iOS 17 perspective parameter changes — On iOS 17+,
rotation3DEffectdefaults to aperspectiveof1. Older tutorials may show a separateCamera3DorprojectionTransformapproach; ignore those — they target UIKit and will add unnecessary complexity. -
Mirrored back face — Forgetting to counter-rotate the back face by subtracting
180° is the #1 flip-card bug. Text and images appear mirror-reversed. Always apply
.degrees(rotation - 180)on the hidden side. -
Stacking two rotation3DEffect modifiers isn't additive by default — When chaining
two
rotation3DEffectcalls for multi-axis tilt (like the gesture variant above), each modifier operates in the view's local coordinate space after the previous transform, so order matters. Test both orderings if the tilt direction feels inverted. -
Accessibility: don't rely on animation to convey state — VoiceOver users can't
perceive the flip. Always update
accessibilityLabelto reflect the current visible state after a flip.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement 3D rotation in SwiftUI for iOS 17+. Use rotation3DEffect with a configurable axis tuple. Build a flip card with a front and back face. Make it accessible (VoiceOver labels describing which face is shown and "Tap to flip" hint). Add a #Preview with realistic sample data (e.g. a loyalty card with name and points).
In Soarias, paste this into the Build phase prompt bar alongside your screen mockup to get a production-ready flip card dropped directly into your SwiftUI file.
Related
FAQ
Does this work on iOS 16?
rotation3DEffect itself has been available since iOS 13, so the basic API works on iOS 16.
However, the .spring(duration:bounce:) initialiser used in the examples requires
iOS 17+. On iOS 16 you can substitute .spring(response:dampingFraction:). The
#Preview macro also requires Xcode 15 / iOS 17 SDK; use PreviewProvider
if you still target iOS 16.
How do I stop the back face from looking mirrored?
Apply a second rotation3DEffect of .degrees(-180) on the same axis to the
back face, or use the rotation - 180 technique shown in the full implementation above.
Both approaches cancel the visual mirror effect so text and images appear correctly oriented when the
card finishes flipping.
What's the UIKit equivalent?
In UIKit you'd use CATransform3DRotate on a CALayer, set
layer.zPosition for depth ordering, and drive the animation with
CABasicAnimation(keyPath: "transform"). SwiftUI's
rotation3DEffect abstracts all of that into a single view modifier and wires it
automatically to SwiftUI's animation system — no layer manipulation or key-path strings required.
Last reviewed: 2026-05-11 by the Soarias team.