AG
Writing
Engineering3 min read

Crafting App Store-Style Card Animations with SwiftUI

Recreate the App Store Today tab's signature card-to-fullscreen transition using matchedGeometryEffect, Namespace, and clean view composition.

Charith 'Alex' Gunasekara

Charith 'Alex' Gunasekara

Head of Development & Engineering

Originally published on Medium
SwiftUIiOSAnimationMobile

The App Store's Today tab is a masterclass in smooth, immersive interaction — especially the way a card expands into a full-screen view with one fluid motion. The good news: SwiftUI gives you everything you need to recreate it with matchedGeometryEffect, a shared Namespace, and a little view composition.

The card list

Start with a model and a reusable card.

struct CardModel: Identifiable {
    let id = UUID()
    let title: String
    let color: Color
}
 
struct CardView: View {
    let card: CardModel
 
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 20)
                .fill(card.color)
            Text(card.title)
                .font(.title2)
                .foregroundColor(.white)
        }
    }
}

The home view declares a @Namespace (the glue for matchedGeometryEffect) and tracks which card is selected and whether the detail is showing. On tap, it sets the selection and animates with a spring; the matching geometry IDs let the small card and the full-screen card share one continuous transition.

struct HomeListView: View {
    @Namespace var animation
    @State private var selectedCard: CardModel?
    @State private var showDetail = false
 
    let cards = [
        CardModel(title: "SwiftUI Magic", color: .blue),
        CardModel(title: "iOS Animations", color: .green),
        CardModel(title: "UI Components", color: .purple)
    ]
 
    var body: some View {
        NavigationStack {
            ZStack {
                ScrollView {
                    VStack(spacing: 20) {
                        ForEach(cards) { card in
                            CardView(card: card)
                                .matchedGeometryEffect(id: card.id, in: animation)
                                .frame(height: 200)
                                .onTapGesture {
                                    selectedCard = card
                                    withAnimation(
                                        .spring(response: 0.4, dampingFraction: 0.8)
                                    ) {
                                        showDetail = true
                                    }
                                }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Cards")
                .navigationBarTitleDisplayMode(.large)
 
                if let card = selectedCard, showDetail {
                    FullscreenCardView(
                        card: card,
                        animation: animation,
                        show: $showDetail,
                        selectedCard: $selectedCard
                    )
                }
            }
        }
    }
}

The full-screen card

The detail view reuses the same matchedGeometryEffect id, so the colored header appears to grow out of the tapped card. The close button reverses the spring and clears the selection just after the animation settles.

import SwiftUI
 
struct FullscreenCardView: View {
    let card: CardModel
    var animation: Namespace.ID
 
    @Binding var show: Bool
    @Binding var selectedCard: CardModel?
 
    var body: some View {
        ZStack {
            VStack(spacing: 0) {
                // Top colored section
                ZStack(alignment: .topLeading) {
                    RoundedRectangle(cornerRadius: 0)
                        .fill(card.color)
                        .matchedGeometryEffect(id: card.id, in: animation)
                        .frame(height: 200)
 
                    VStack(alignment: .leading, spacing: 4) {
                        HStack(spacing: 0) {
                            Spacer()
                            Button(action: {
                                withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                                    show = false
                                }
                                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                                    selectedCard = nil
                                }
                            }) {
                                Image(systemName: "xmark.circle.fill")
                                    .font(.system(size: 32))
                                    .foregroundColor(.white)
                                    .padding(16)
                            }
                        }
                        .padding(.trailing, 16)
                        .padding(.top, 50)
 
                        Text(card.title)
                            .font(.largeTitle)
                            .bold()
                            .foregroundColor(.white)
                            .padding(.leading, 16)
 
                        Text("This is subtitle")
                            .font(.subheadline)
                            .foregroundColor(.white)
                            .padding(.leading, 16)
                    }
                }
 
                // Bottom white content
                VStack(alignment: .leading, spacing: 8) {
                    Text("Detailed description goes here.")
                        .font(.body)
                        .foregroundColor(.black)
 
                    Text("You can add more components like buttons, images, or even scrollable content.")
                        .font(.subheadline)
                        .foregroundColor(.gray)
 
                    Spacer()
                }
                .padding(.leading, 16)
                .padding(.top, 20)
                .background(Color.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            .ignoresSafeArea()
        }
        .zIndex(1)
        .navigationBarHidden(true)
    }
}

Takeaway

Recreating the App Store-style card animation is a great way to explore advanced SwiftUI motion: matchedGeometryEffect for the shared transition, view transitions for the reveal, and layout composition for the result. With clean state management and reusable components, you get a polished, immersive experience that feels genuinely native.

ShareLinkedInX

Keep reading