Zoom Animations in SwiftUI NavigationStack
Goal
By the end of this tutorial, you will build a zoom-style navigation transition from a card grid into a detail screen using NavigationStack.
State it explicitly:
“Tap a card, navigate to detail, and preserve visual continuity so the transition feels like a zoom instead of a hard screen swap.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+ recommended
The use case
Zoom-like transitions make navigation feel physically connected. Instead of “old screen disappears, new screen appears,” users perceive that one UI element expands into the next context.
In SwiftUI, this quality depends less on one animation modifier and more on how you structure identity and layout. If source and destination do not share stable identity signals, the transition feels disconnected no matter how much animation you add.
Step 1 - The simplest working version
Start with plain NavigationStack from a grid to detail.
import SwiftUI
struct Article: Identifiable, Hashable {
let id: UUID
let title: String
let color: Color
}
struct ArticleGridScreen: View {
let articles: [Article]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {
ForEach(articles) { article in
NavigationLink(value: article) {
ArticleCard(article: article)
}
.buttonStyle(.plain)
}
}
.padding()
}
.navigationDestination(for: Article.self) { article in
ArticleDetail(article: article)
}
.navigationTitle("Articles")
}
}
}
This gives you correct navigation behavior, but no zoom continuity yet.
Quick check: Tap multiple cards and verify navigation path is stable and detail content matches tapped item.
Step 2 - Make it reusable
Now make shared visual structure explicit by extracting a reusable card shell that both list and detail can reference.
struct ArticleCardShell<Content: View>: View {
let color: Color
@ViewBuilder let content: () -> Content
var body: some View {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(color.gradient)
.overlay(content().padding(14), alignment: .bottomLeading)
.frame(height: 180)
.shadow(color: .black.opacity(0.12), radius: 8, y: 4)
}
}
Use this shell in both card and detail header:
struct ArticleCard: View {
let article: Article
var body: some View {
ArticleCardShell(color: article.color) {
Text(article.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(2)
}
}
}
struct ArticleDetail: View {
let article: Article
var body: some View {
ScrollView {
ArticleCardShell(color: article.color) {
Text(article.title)
.font(.largeTitle.bold())
.foregroundStyle(.white)
.lineLimit(3)
}
.frame(height: 280)
.padding()
Text("Detail content...")
.padding(.horizontal)
}
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
}
}
What changed: You created shared visual DNA between source and destination, which is a prerequisite for believable zoom transitions.
Quick check: Even without extra animation, transition should already feel less abrupt because shape/color hierarchy is consistent.
Step 3 - SwiftUI-specific refinement
Add explicit animation boundaries and predictable identity during selection.
struct ArticleGridScreen: View {
let articles: [Article]
@State private var selectedID: Article.ID?
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [.init(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {
ForEach(articles) { article in
NavigationLink(value: article) {
ArticleCard(article: article)
.scaleEffect(selectedID == article.id ? 0.97 : 1)
.opacity(selectedID == article.id ? 0.85 : 1)
.animation(.easeOut(duration: 0.16), value: selectedID)
}
.simultaneousGesture(TapGesture().onEnded {
selectedID = article.id
})
.buttonStyle(.plain)
}
}
.padding()
}
.navigationDestination(for: Article.self) { article in
ArticleDetail(article: article)
.onDisappear { selectedID = nil }
}
.navigationTitle("Articles")
}
}
}
This is not a “magic zoom modifier.” It is a continuity strategy: stable identity, shared visual structure, and small source-state animation that reduces perceptual jump.
Quick check: Tap quickly between different cards and ensure selected-state animation resets cleanly without stale highlight.
Result
You now have a navigation flow that feels:
- connected
- intentional
- closer to zoom-style motion instead of hard replacement
Common SwiftUI pitfalls
-
Mistake: Animating everything globally
Why it happens: applying.animationtoo high in the tree
How to fix it: animate only state relevant to the transition (selectedID, local scale/opacity) -
Mistake: Unstable identity in data models
Why it happens: IDs regenerated on render or recomputation
How to fix it: keep stableidvalues tied to model lifecycle -
Mistake: Source and destination share no visual structure
Why it happens: detail screen uses unrelated hero design
How to fix it: preserve at least one recognizable shell (shape, color, spacing rhythm)
When NOT to use this
If the destination context is intentionally unrelated (for example, modal tools or destructive confirmation screens), forcing zoom continuity can feel misleading. Use simpler transitions when conceptual continuity is weak.
Takeaway
If you remember one thing: Zoom-feeling navigation in SwiftUI comes from continuity design, not from one animation API.
Next steps
If you want to deepen this style of transition work, continue with:
- Building an expandable add button in SwiftUI
- Why SwiftUI Recomposes Views (and Why That’s Fine)
- Views are value types, not objects
External inspiration: