matchedGeometryEffect
matchedGeometryEffect establishes a geometric relationship between different views, allowing them to animate smoothly when transitioning across:
- Different layouts
- Different containers
- Different conditional render states
- Different size and position configurations
It corresponds to SwiftUI’s matchedGeometryEffect and is a component-level geometry animation system, independent of navigation.
1. API Definition
1matchedGeometryEffect?: {
2 id: string | number
3 namespace: NamespaceID
4 properties?: MatchedGeometryProperties
5 anchor?: Point | KeywordPoint
6 isSource?: boolean
7}
1type MatchedGeometryProperties = "frame" | "position" | "size"
2. Core Purpose
The core purpose of matchedGeometryEffect is:
To make two views that represent the same logical element share geometry information across different layouts, producing a continuous animated transition instead of a visual jump.
This solves issues such as:
- Sudden jumps when a view moves between containers
- Abrupt size changes when expanding a card
- Layout discontinuity between list and detail views
- Teleport-like behavior of tab indicators
3. Parameter Details
3.1 id — Geometry Matching Identifier
Rules:
- The
id must remain stable during animation.
- One
id can have only one isSource = true at any moment.
3.2 namespace — Geometry Namespace
- Defines the animation scope.
- Even if two views share the same
id, they will not animate unless the namespace is also the same.
- Must be created and injected via
NamespaceReader.
Rules:
- Source and target must use the exact same namespace instance.
- Cross-namespace matching is not allowed.
3.3 properties — Geometry Properties to Match
1properties?: "frame" | "position" | "size"
Default:
Meaning:
| Value |
Description |
"frame" |
Matches both position and size |
"position" |
Matches only the center position |
"size" |
Matches only width and height |
Guidelines:
- Use
"frame" for natural transitions
- Use
"position" for indicators and sliding highlights
- Use
"size" for zooming and expansion effects
3.4 anchor — Animation Anchor Point
1anchor?: Point | KeywordPoint
Default:
Controls how the geometry alignment is calculated during animation.
Common values:
"center"
"topLeading"
"topTrailing"
"bottomLeading"
"bottomTrailing"
Usage examples:
- Expanding a card from the top-left
- Zooming an avatar from the top-right
- Sliding a panel upward from the bottom
3.5 isSource — Geometry Data Provider
Default:
Meaning:
| Value |
Behavior |
true |
This view provides geometry data |
false |
This view receives geometry animation |
Standard pattern:
- Original view →
isSource: true
- Target view →
isSource: false
If omitted:
- The first appearing view becomes the source by default.
4. Minimal Working Example (Position + Size Matching)
This example shows a circle moving and scaling smoothly between two containers.
1const expanded = useObservable(false)
2
3<NamespaceReader>
4 {namespace => (
5 <VStack spacing={40}>
6 <Button
7 title="Toggle"
8 onTapGesture={() => {
9 expanded.setValue(!expanded.value)
10 }}
11 />
12
13 <ZStack
14 frame={{ width: 300, height: 200 }}
15 background="systemGray6"
16 >
17 {!expanded.value && (
18 <Circle
19 fill="systemOrange"
20 frame={{ width: 60, height: 60 }}
21 matchedGeometryEffect={{
22 id: "circle",
23 namespace
24 }}
25 />
26 )}
27 </ZStack>
28
29 <ZStack
30 frame={{ width: 300, height: 300 }}
31 background="systemGray4"
32 >
33 {expanded.value && (
34 <Circle
35 fill="systemOrange"
36 frame={{ width: 150, height: 150 }}
37 matchedGeometryEffect={{
38 id: "circle",
39 namespace,
40 isSource: false
41 }}
42 />
43 )}
44 </ZStack>
45 </VStack>
46 )}
47</NamespaceReader>
Behavior
5. Position-Only Matching (Tab Indicator)
1const selected = useObservable(0)
2
3<NamespaceReader>
4 {namespace => (
5 <HStack spacing={24}>
6 <Text
7 onTapGesture={() => selected.setValue(0)}
8 matchedGeometryEffect={{
9 id: "indicator",
10 namespace,
11 properties: "position",
12 isSource: selected.value === 0
13 }}
14 >
15 Tab 1
16 </Text>
17
18 <Text
19 onTapGesture={() => selected.setValue(1)}
20 matchedGeometryEffect={{
21 id: "indicator",
22 namespace,
23 properties: "position",
24 isSource: selected.value === 1
25 }}
26 >
27 Tab 2
28 </Text>
29 </HStack>
30 )}
31</NamespaceReader>
Used for:
- Tab selection indicators
- Sliding highlights
- Moving selection backgrounds
6. Size-Only Matching (Zoom Animation)
1const expanded = useObservable(false)
2
3<NamespaceReader>
4 {namespace => (
5 <ZStack>
6 <Circle
7 fill="systemBlue"
8 frame={{
9 width: expanded.value ? 200 : 80,
10 height: expanded.value ? 200 : 80
11 }}
12 matchedGeometryEffect={{
13 id: "avatar",
14 namespace,
15 properties: "size"
16 }}
17 onTapGesture={() => {
18 expanded.setValue(!expanded.value)
19 }}
20 />
21 </ZStack>
22 )}
23</NamespaceReader>
Suitable for:
- Avatar zooming
- Card expansion
- Press feedback animations
7. Multi-Element Matching (Card → Detail View)
1const showDetail = useObservable(false)
2
3<NamespaceReader>
4 {namespace => (
5 <ZStack>
6 {!showDetail.value && (
7 <VStack spacing={16}>
8 <Image
9 source="cover"
10 matchedGeometryEffect={{
11 id: "card.image",
12 namespace
13 }}
14 onTapGesture={() => {
15 showDetail.setValue(true)
16 }}
17 />
18
19 <Text
20 matchedGeometryEffect={{
21 id: "card.title",
22 namespace
23 }}
24 >
25 Card Title
26 </Text>
27 </VStack>
28 )}
29
30 {showDetail.value && (
31 <VStack spacing={24}>
32 <Image
33 source="cover"
34 frame={{ width: 300, height: 200 }}
35 matchedGeometryEffect={{
36 id: "card.image",
37 namespace,
38 isSource: false
39 }}
40 />
41
42 <Text
43 font="largeTitle"
44 matchedGeometryEffect={{
45 id: "card.title",
46 namespace,
47 isSource: false
48 }}
49 >
50 Card Title
51 </Text>
52 </VStack>
53 )}
54 </ZStack>
55 )}
56</NamespaceReader>
Effect:
- Image and title animate together
- Transition from compact card to expanded detail layout
- No navigation system required
8. Key Usage Rules
-
namespace must be identical
-
id must be identical
-
At any time:
- One
id → only one isSource = true
-
Default behavior:
1properties = "frame"
2anchor = "center"
3isSource = true
-
Source and target must switch within the same render cycle
-
If both views are marked as isSource: true, results are undefined
-
Live Activity and Widget environments do not fully support matched geometry animations
9. Suitable Use Cases
Recommended:
- Tab indicators
- Card-to-detail transitions
- Image zoom previews
- List selection animations
- Split-view selection synchronization
Not recommended:
- High-frequency updating lists
- Large grids with many simultaneous matches
- Real-time chart rendering