Animating View Transitions with .transition
Goal
By the end of this tutorial, you will build a SwiftUI panel that appears and disappears with a clean transition, and you will understand why some transitions seem to “not work”.
State it explicitly:
“Animate conditional views in and out reliably using transition and state-driven updates.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+
The use case
Most SwiftUI animation confusion happens when a view is conditionally inserted with if and developers expect a modifier animation to handle everything automatically.
Sometimes it works, sometimes it snaps.
The root issue is simple: transitions apply when a view enters or exits the hierarchy. If your state updates and hierarchy changes are not coordinated, transition timing feels random.
This tutorial builds the pattern in small steps so behavior stays predictable.
Step 1 - The simplest working version
Create one state flag and one conditional panel.
import SwiftUI
struct TransitionDemoView: View {
@State private var isOpen = false
var body: some View {
VStack(spacing: 16) {
Button(isOpen ? "Hide details" : "Show details") {
withAnimation(.easeInOut(duration: 0.25)) {
isOpen.toggle()
}
}
if isOpen {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.blue.opacity(0.15))
.frame(height: 140)
.overlay {
Text("Transition panel")
.font(.headline)
}
.transition(.opacity)
}
}
.padding()
}
}
What this already does correctly:
- state controls insertion/removal
withAnimationwraps the hierarchy change- transition is attached to the conditional view
Quick verification checkpoint: click quickly multiple times and confirm fade-in and fade-out both animate.
Step 2 - Make it reusable
Extract a reusable transition container so feature screens do not repeat transition boilerplate.
struct TransitionContainer<Content: View>: View {
let isVisible: Bool
let transition: AnyTransition
@ViewBuilder let content: () -> Content
var body: some View {
Group {
if isVisible {
content()
.transition(transition)
}
}
}
}
Use it:
TransitionContainer(
isVisible: isOpen,
transition: .opacity.combined(with: .move(edge: .top))
) {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.mint.opacity(0.2))
.frame(height: 140)
.overlay { Text("Reusable transition") }
}
What changed: transition behavior is now a reusable contract instead of inlined conditional logic.
Quick verification checkpoint:
reuse TransitionContainer with a second block and a different transition.
Step 3 - SwiftUI-specific refinement
Add layout stability and predictable motion when content size changes.
struct TransitionDemoView: View {
@State private var isOpen = false
@State private var showExtraLine = false
var body: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
Button(isOpen ? "Hide details" : "Show details") {
withAnimation(.snappy(duration: 0.3)) {
isOpen.toggle()
}
}
Button(showExtraLine ? "Compact" : "Expand text") {
withAnimation(.easeInOut(duration: 0.2)) {
showExtraLine.toggle()
}
}
.disabled(!isOpen)
}
TransitionContainer(
isVisible: isOpen,
transition: .opacity.combined(with: .move(edge: .top))
) {
VStack(alignment: .leading, spacing: 8) {
Text("Shipping details")
.font(.headline)
Text("Your package will be dispatched in 24 hours.")
.foregroundStyle(.secondary)
if showExtraLine {
Text("Express options are available at checkout.")
.foregroundStyle(.secondary)
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
)
}
}
.padding()
}
}
Refinement points:
snappymakes the main panel feel responsive- nested conditional text gets its own transition
- background shape stays stable, which reduces visual jump
Quick verification checkpoint: open panel, toggle extra line repeatedly, then close panel. Motion should remain readable and not collapse abruptly.
Result
You now have a transition pattern that is:
- state-driven
- reusable
- stable under content variation
Common SwiftUI pitfalls
-
Mistake: Adding
.transition(...)without conditional insertion/removal
Why it happens: expecting transition to animate static views
How to fix it: attach transition to a view controlled byifor dynamic collection changes -
Mistake: Placing
withAnimationin unrelated scopes
Why it happens: animation wrapped around side effects instead of hierarchy change
How to fix it: wrap the exact state mutation that inserts/removes the view -
Mistake: Mixing container size animation and transition without intent
Why it happens: multiple implicit animations compete
How to fix it: apply focused animations and keep one source of truth for visibility
When NOT to use this
If your transition depends on shared geometry between two different screens, use a matched-geometry strategy. transition is ideal for local hierarchy insertion/removal within one view tree.
Takeaway
If you remember one thing: Transitions animate hierarchy changes, not static views.
Next steps
If you want to go deeper into motion and state continuity, these are the most useful follow-ups: