Skip to content

Matched Hero Card

A card that expands in place into a full detail view using matched geometry.

Matched Hero Card, playing
Download .swift
Origin
Original work
Found by
hero animation · matched geometry · expanding card · card · detail · morph · app store card · animación hero · geometría compartida · tarjeta expandible · tarjeta · detalle · transformación

A card that grows in place to fill its parent. Its icon, title and subtitle travel to their new places with matchedGeometryEffect.

With Reduce Motion on, the card changes state without animating.

struct MatchedHeroCardDemo: View {
@ScaledMetric(relativeTo: .largeTitle) private var rowsOffset = 240.0
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Today")
.font(.largeTitle.bold())
ZStack(alignment: .top) {
VStack(spacing: 12) {
row("Harbor walk", systemImage: "sailboat.fill")
row("Lunch at the market", systemImage: "fork.knife")
row("Sunset viewpoint", systemImage: "sun.horizon.fill")
}
.padding(.top, rowsOffset)
MatchedHeroCard(
title: "Lisbon",
subtitle: "Three days by the river",
systemImage: "tram.fill",
colors: [.orange, .pink, .purple]
) {
Text("Tiled streets, yellow trams and the best custard tarts you will ever taste. The card grew from where it was and goes back there when you close it.")
.font(.body)
}
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(.systemGroupedBackground).ignoresSafeArea())
}
private func row(_ title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 18))
}
}
ParameterTypeDefault
titleStringRequired
subtitleStringRequired
systemImageStringRequired
colors[Color][.indigo, .purple]
detail() -> DetailRequired

Copy this code into your project, or download the file.

MatchedHeroCard.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A card that grows in place to fill its parent. Its icon, title and subtitle travel to their
/// new places with `matchedGeometryEffect`.
///
/// With Reduce Motion on, the card changes state without animating.
struct MatchedHeroCard<Detail: View>: View {
let title: String
let subtitle: String
let systemImage: String
var colors: [Color] = [.indigo, .purple]
@ViewBuilder var detail: () -> Detail
@State private var isExpanded = false
@Namespace private var namespace
@ScaledMetric(relativeTo: .largeTitle) private var collapsedHeight = 220.0
@ScaledMetric(relativeTo: .largeTitle) private var iconSize = 52.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var animation: Animation? {
reduceMotion ? nil : .smooth(duration: 0.5, extraBounce: 0.08)
}
var body: some View {
// One background for both states, so the card is never see-through while it changes,
// and it clips the detail, so the text never runs over what is below the card.
ZStack(alignment: .top) {
if isExpanded {
expandedContent
} else {
collapsedContent
}
}
.frame(maxWidth: .infinity, minHeight: collapsedHeight, maxHeight: isExpanded ? .infinity : collapsedHeight)
.background(LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing))
.clipShape(.rect(cornerRadius: 28, style: .continuous))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.foregroundStyle(.white)
}
private var collapsedContent: some View {
Button {
withAnimation(animation) { isExpanded = true }
} label: {
header
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityHint("Expands the card")
}
private var expandedContent: some View {
VStack(alignment: .leading, spacing: 24) {
header
detail()
Spacer(minLength: 0)
}
.padding(24)
.padding(.top, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .topTrailing) {
Button {
withAnimation(animation) { isExpanded = false }
} label: {
Image(systemName: "xmark")
.font(.headline)
.padding(12)
.background(.white.opacity(0.2), in: .circle)
// A little bigger for a touch than it looks, up to the size of a touch target.
.padding(2)
.contentShape(.rect)
.padding(-2)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
.padding(16)
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: iconSize, weight: .semibold))
.matchedGeometryEffect(id: "icon", in: namespace)
.accessibilityHidden(true)
Text(title)
.font(.largeTitle.bold())
.matchedGeometryEffect(id: "title", in: namespace)
Text(subtitle)
.font(.subheadline)
.opacity(0.85)
.matchedGeometryEffect(id: "subtitle", in: namespace)
}
}
}
// MARK: - Demo
struct MatchedHeroCardDemo: View {
@ScaledMetric(relativeTo: .largeTitle) private var rowsOffset = 240.0
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Today")
.font(.largeTitle.bold())
ZStack(alignment: .top) {
VStack(spacing: 12) {
row("Harbor walk", systemImage: "sailboat.fill")
row("Lunch at the market", systemImage: "fork.knife")
row("Sunset viewpoint", systemImage: "sun.horizon.fill")
}
.padding(.top, rowsOffset)
MatchedHeroCard(
title: "Lisbon",
subtitle: "Three days by the river",
systemImage: "tram.fill",
colors: [.orange, .pink, .purple]
) {
Text("Tiled streets, yellow trams and the best custard tarts you will ever taste. The card grew from where it was and goes back there when you close it.")
.font(.body)
}
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(.systemGroupedBackground).ignoresSafeArea())
}
private func row(_ title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 18))
}
}
#Preview {
MatchedHeroCardDemo()
}