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

1id: string | number
  • Identifies which views belong to the same geometry group.

  • Only views with the same id inside the same namespace will match.

  • Typically derived from:

    • Model identifiers
    • Index values
    • Stable business keys

Rules:

  • The id must remain stable during animation.
  • One id can have only one isSource = true at any moment.

3.2 namespace — Geometry Namespace

1namespace: NamespaceID
  • 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:

1properties = "frame"

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:

1anchor = "center"

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

1isSource?: boolean

Default:

1isSource = true

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

  • The same logical circle:

    • Moves downward
    • Grows in size
    • Maintains continuous animation
  • No visual teleportation occurs


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

  1. namespace must be identical

  2. id must be identical

  3. At any time:

    • One id → only one isSource = true
  4. Default behavior:

    1properties = "frame"
    2anchor = "center"
    3isSource = true
  5. Source and target must switch within the same render cycle

  6. If both views are marked as isSource: true, results are undefined

  7. 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