Zoom Hero Transition
A grid of cards that zoom into their detail screen with the native navigation zoom transition.

- Transitions
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- zoom transition · hero animation · navigation · grid · cards · detail · matched transition · transición zoom · animación hero · navegación · cuadrícula · tarjetas · detalle
Description
Section titled “Description”A grid of cards that zoom into their detail screen. Place it inside a NavigationStack.
With Reduce Motion on, the detail opens with the standard navigation transition.
private struct ZoomHeroDemoPlace: Identifiable, Hashable { let id: String let systemImage: String let colors: [Color]}
struct ZoomHeroTransitionDemo: View { private let places = [ ZoomHeroDemoPlace(id: "Aurora", systemImage: "sparkles", colors: [.indigo, .teal]), ZoomHeroDemoPlace(id: "Dunes", systemImage: "sun.max.fill", colors: [.orange, .pink]), ZoomHeroDemoPlace(id: "Forest", systemImage: "leaf.fill", colors: [.green, .mint]), ZoomHeroDemoPlace(id: "Glacier", systemImage: "snowflake", colors: [.cyan, .blue]), ZoomHeroDemoPlace(id: "Volcano", systemImage: "flame.fill", colors: [.red, .orange]), ZoomHeroDemoPlace(id: "Night", systemImage: "moon.stars.fill", colors: [.purple, .indigo]), ]
var body: some View { NavigationStack { ZoomHeroTransition(items: places) { place in LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .aspectRatio(0.78, contentMode: .fit) .overlay { Image(systemName: place.systemImage) .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white.opacity(0.9)) .accessibilityHidden(true) } .overlay(alignment: .bottomLeading) { Text(place.id) .font(.headline) .foregroundStyle(.white) .padding(14) } } detail: { place in ZoomHeroDemoDetail(place: place) } .navigationTitle("Places") } }}
private struct ZoomHeroDemoDetail: View { let place: ZoomHeroDemoPlace
@Environment(\.dismiss) private var dismiss
var body: some View { ZStack { LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
VStack(spacing: 18) { Image(systemName: place.systemImage) .font(.system(.largeTitle, weight: .semibold)) .imageScale(.large) .accessibilityHidden(true)
Text(place.id) .font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("The card grew into this screen. Swipe down or tap Close to send it back.") .font(.body) .multilineTextAlignment(.center)
Button("Close") { dismiss() } .buttonStyle(.bordered) .tint(.white) } .foregroundStyle(.white) .padding(32) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
items | [Item] | Required |
columns | Int | 2 |
cornerRadius | CGFloat | 24 |
card | (Item) -> Card | Required |
detail | (Item) -> Detail | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A grid of cards that zoom into their detail screen. Place it inside a `NavigationStack`.////// With Reduce Motion on, the detail opens with the standard navigation transition.struct ZoomHeroTransition<Item: Identifiable & Hashable, Card: View, Detail: View>: View { let items: [Item] var columns: Int = 2 var cornerRadius: CGFloat = 24 @ViewBuilder var card: (Item) -> Card @ViewBuilder var detail: (Item) -> Detail
@Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ScrollView { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: columns), spacing: 14) { ForEach(items) { item in NavigationLink(value: item) { card(item) .clipShape(.rect(cornerRadius: cornerRadius)) .matchedTransitionSource(id: item.id, in: namespace) { source in source.clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } } .buttonStyle(.plain) } } .padding(16) } .navigationDestination(for: Item.self) { item in destination(for: item) } }
// `.zoom` and `.automatic` are different types, so a ternary cannot choose between them on iOS 18. @ViewBuilder private func destination(for item: Item) -> some View { if reduceMotion { detail(item) } else { detail(item).navigationTransition(.zoom(sourceID: item.id, in: namespace)) } }}
// MARK: - Demo
private struct ZoomHeroDemoPlace: Identifiable, Hashable { let id: String let systemImage: String let colors: [Color]}
struct ZoomHeroTransitionDemo: View { private let places = [ ZoomHeroDemoPlace(id: "Aurora", systemImage: "sparkles", colors: [.indigo, .teal]), ZoomHeroDemoPlace(id: "Dunes", systemImage: "sun.max.fill", colors: [.orange, .pink]), ZoomHeroDemoPlace(id: "Forest", systemImage: "leaf.fill", colors: [.green, .mint]), ZoomHeroDemoPlace(id: "Glacier", systemImage: "snowflake", colors: [.cyan, .blue]), ZoomHeroDemoPlace(id: "Volcano", systemImage: "flame.fill", colors: [.red, .orange]), ZoomHeroDemoPlace(id: "Night", systemImage: "moon.stars.fill", colors: [.purple, .indigo]), ]
var body: some View { NavigationStack { ZoomHeroTransition(items: places) { place in LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .aspectRatio(0.78, contentMode: .fit) .overlay { Image(systemName: place.systemImage) .font(.largeTitle) .imageScale(.large) .foregroundStyle(.white.opacity(0.9)) .accessibilityHidden(true) } .overlay(alignment: .bottomLeading) { Text(place.id) .font(.headline) .foregroundStyle(.white) .padding(14) } } detail: { place in ZoomHeroDemoDetail(place: place) } .navigationTitle("Places") } }}
private struct ZoomHeroDemoDetail: View { let place: ZoomHeroDemoPlace
@Environment(\.dismiss) private var dismiss
var body: some View { ZStack { LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
VStack(spacing: 18) { Image(systemName: place.systemImage) .font(.system(.largeTitle, weight: .semibold)) .imageScale(.large) .accessibilityHidden(true)
Text(place.id) .font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("The card grew into this screen. Swipe down or tap Close to send it back.") .font(.body) .multilineTextAlignment(.center)
Button("Close") { dismiss() } .buttonStyle(.bordered) .tint(.white) } .foregroundStyle(.white) .padding(32) } }}
#Preview { ZoomHeroTransitionDemo()}