You’ve got your views appearing and disappearing. .opacity fade, .slide from the side, maybe .scale if you’re feeling adventurous. It works. But it also looks like every other SwiftUI app out there.

The moment you want something that actually fits your design - a card flip, a rotation with a fade, an asymmetric entrance and exit - you hit a wall. Built-in transitions don’t combine the way you’d expect, and the docs don’t make it obvious how to build your own.

Good news: custom transitions in SwiftUI are simpler than they look once you see the pattern.

Think of It Like Stage Directions

Every transition is really just two states: where the view starts (or ends), and where it settles. Think of it like stage directions in a play. An actor doesn’t just “appear” - they walk in from stage left, or the lights fade up on them, or they spin around from behind a curtain.

SwiftUI transitions work the same way. You define the “offstage” look (rotated, transparent, scaled down) and the “onstage” look (normal). SwiftUI handles the animation between the two.

The Building Block: Transition Modifiers

Before building custom ones, here’s how the built-in transitions work with conditional views:

struct ContentView: View {
    @State private var showDetail = false

    var body: some View {
        VStack {
            if showDetail {
                DetailView()
                    .transition(.slide)
            }

            Button("Toggle") {
                withAnimation {
                    showDetail.toggle()
                }
            }
        }
    }
}

The key: transitions only apply when views are added to or removed from the view hierarchy. Toggling a property that controls an if statement is what triggers them.

Your First Custom Transition

The pattern is always the same - define two states of a ViewModifier:

struct PivotModifier: ViewModifier {
    let rotation: Double
    let opacity: Double

    func body(content: Content) -> some View {
        content
            .rotationEffect(.degrees(rotation))
            .opacity(opacity)
    }
}

extension AnyTransition {
    static var pivot: AnyTransition {
        // Offstage: rotated 90° and invisible
        // Onstage: no rotation, fully visible
        .modifier(
            active: PivotModifier(rotation: 90, opacity: 0),
            identity: PivotModifier(rotation: 0, opacity: 1)
        )
    }
}

active is the offstage state - what the view looks like when it’s entering or leaving. identity is the onstage state - what the view looks like when it’s settled in place. SwiftUI animates between them.

That’s the whole pattern. Every custom transition you’ll ever build follows this structure.

Combining and Mixing Transitions

You can layer transitions together, or use different ones for entrance and exit:

extension AnyTransition {
    // Both effects play at the same time
    static var slideAndScale: AnyTransition {
        .slide.combined(with: .scale)
    }

    // Different entrance and exit - slides in from right, out to left
    static var directional: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .move(edge: .leading).combined(with: .opacity)
        )
    }
}

.asymmetric is especially useful for navigation-style flows where you want views to feel like they’re moving in a consistent direction.

A Card Flip Transition

Here’s a more involved example - a 3D card flip using rotation3DEffect:

struct FlipModifier: ViewModifier {
    let rotation: Double

    func body(content: Content) -> some View {
        content
            .rotation3DEffect(
                .degrees(rotation),
                axis: (x: 0, y: 1, z: 0),
                // Lower perspective = more dramatic 3D effect
                perspective: 0.5
            )
    }
}

extension AnyTransition {
    static var flip: AnyTransition {
        // Offstage: rotated 90° on the Y axis (edge-on, invisible)
        // Onstage: facing the camera
        .modifier(
            active: FlipModifier(rotation: 90),
            identity: FlipModifier(rotation: 0)
        )
    }
}

Same pattern, just a different modifier underneath. Once you internalize “active + identity”, you can build any transition you can imagine.

Respect Your Users’ Preferences

Some users have reduced motion enabled for good reasons. Check for it and fall back to something simple:

@Environment(\.accessibilityReduceMotion) var reduceMotion

var transition: AnyTransition {
    reduceMotion ? .opacity : .pivot
}

This isn’t optional polish - it’s the baseline for shipping accessible animations. A simple .opacity fallback works for almost any custom transition.

Key Takeaways

  1. Every transition is two states - “offstage” (active) and “onstage” (identity). SwiftUI animates between them
  2. The pattern never changes - a ViewModifier with two configurations. Once you’ve built one custom transition, you can build any of them
  3. Combine freely - .combined(with:) layers effects, .asymmetric gives you different entrances and exits
  4. Always check for reduced motion - a one-line @Environment check keeps your animations accessible
  5. Transitions need view insertion/removal - they only fire on if/else toggling, not on property changes within an existing view