swiftui · tutorial · animation · layout

Zoom Animations in SwiftUI NavigationStack

Published July 20, 2026 · 4 min read · intermediate

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 .animation too 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 stable id values 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:

External inspiration: