```html SwiftUI: How to Build 3D Rotation (iOS 17+, 2026)

How to Build 3D Rotation in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: rotation3DEffect Updated: May 11, 2026
TL;DR

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

  1. .rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0)) — The axis tuple picks the direction of spin. Setting y: 1 while keeping x and z at 0 produces a horizontal-card flip. Swap y for x to flip top-to-bottom, or z to spin like a wheel.
  2. 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°.
  3. Opacity crossfade at 90° — Setting .opacity(flipped ? 1 : 0) and .opacity(flipped ? 0 : 1) swaps visibility the moment flipped toggles. SwiftUI's animation system interpolates these together with the rotation so the switch looks seamless.
  4. withAnimation(.spring(duration: 0.6, bounce: 0.15)) — A spring animation gives the flip a satisfying overshoot. Increasing bounce adds more wobble; set it to 0 for a crisp, linear flip.
  5. Accessibility — Because the visual flip communicates state, the accessibilityLabel describes 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

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.

```