AniUI

Matched Geometry

SwiftUI-style shared-element layout transition primitive — morphs one tagged view into another's position and size when they swap.

Tap to morph

Web preview — components render natively on iOS & Android
import { useState } from "react";
import { Pressable, View } from "react-native";
import { MatchedGeometryProvider, MatchedGeometryView } from "@/components/ui/matched-geometry";

function Example() {
  const [expanded, setExpanded] = useState(false);

  return (
    <MatchedGeometryProvider>
      <Pressable onPress={() => setExpanded((v) => !v)}>
        {expanded ? (
          <MatchedGeometryView id="card" className="h-40 w-full rounded-2xl bg-primary" />
        ) : (
          <MatchedGeometryView id="card" className="h-16 w-16 rounded-full bg-primary" />
        )}
      </Pressable>
    </MatchedGeometryProvider>
  );
}

Installation#

npx @aniui/cli add matched-geometry

Usage#

MatchedGeometryView is a drop-in wrapper around the lower-level useMatchedGeometry hook. Wrap the screen (or region) in a MatchedGeometryProvider, then tag two views that mount/unmount in turn with the same id — swapping between them morphs the shared element's position and size instead of a hard cut. Reach for the hook directly if you need full control over the content being rendered during the transition.

import { useState } from "react";
import { Pressable, View } from "react-native";
import { MatchedGeometryProvider, MatchedGeometryView } from "@/components/ui/matched-geometry";

function Example() {
  const [expanded, setExpanded] = useState(false);

  return (
    <MatchedGeometryProvider>
      <Pressable onPress={() => setExpanded((v) => !v)}>
        {expanded ? (
          <MatchedGeometryView id="card" className="h-40 w-full rounded-2xl bg-primary" />
        ) : (
          <MatchedGeometryView id="card" className="h-16 w-16 rounded-full bg-primary" />
        )}
      </Pressable>
    </MatchedGeometryProvider>
  );
}

Source#

components/ui/matched-geometry.tsx
import React, {
  createContext,
  useCallback,
  useContext,
  useMemo,
  useRef,
  useState,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Animated, {
  Easing,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  type AnimatedStyle,
} from "react-native-reanimated";
import { cn } from "@/lib/utils";

/**
 * SwiftUI-`matchedGeometryEffect`-style shared-element transition primitive.
 *
 * Tag two `MatchedGeometryView`s with the same `id` — mounted and unmounted
 * in turn as a boolean/id-based state flips elsewhere in the tree (e.g. a
 * list-item view swapped for a detail-view) — and the outgoing view's last
 * measured rect morphs into the incoming view's freshly measured rect via a
 * shared animated overlay, while the incoming view cross-fades in over the
 * same duration. Only one `MatchedGeometryView` per `id` should be mounted
 * at a time; the provider should wrap the screen (or region) the transition
 * should be visible across.
 */

interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface MatchedGeometryContextValue {
  registerRect: (id: string, rect: Rect) => void;
  requestTransition: (
    id: string,
    rect: Rect,
    node: React.ReactNode,
    duration: number,
  ) => boolean;
  originRef: React.RefObject<{ x: number; y: number }>;
}

const MatchedGeometryContext =
  createContext<MatchedGeometryContextValue | null>(null);

const DEFAULT_TRANSITION_DURATION = 350;
const ZERO_RECT: Rect = { x: 0, y: 0, width: 0, height: 0 };
const TRANSITION_EASING = Easing.out(Easing.cubic);

export interface MatchedGeometryProviderProps
  extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
}

/** Wrap the screen (or region) within which shared-element transitions should be visible. */
export function MatchedGeometryProvider({
  children,
  className,
  style,
  ...props
}: MatchedGeometryProviderProps) {
  const rects = useRef<Map<string, Rect>>(new Map());
  const originRef = useRef({ x: 0, y: 0 });
  const rootRef = useRef<View>(null);
  const [overlayNode, setOverlayNode] = useState<React.ReactNode>(null);

  const from = useSharedValue<Rect>(ZERO_RECT);
  const to = useSharedValue<Rect>(ZERO_RECT);
  const progress = useSharedValue(0);

  const clearOverlay = useCallback(() => setOverlayNode(null), []);

  const registerRect = useCallback((id: string, rect: Rect) => {
    rects.current.set(id, rect);
  }, []);

  const requestTransition = useCallback(
    (id: string, rect: Rect, node: React.ReactNode, duration: number) => {
      const previous = rects.current.get(id);
      rects.current.set(id, rect);
      if (!previous) return false;

      from.value = previous;
      to.value = rect;
      progress.value = 0;
      setOverlayNode(node);
      progress.value = withTiming(
        1,
        { duration, easing: TRANSITION_EASING },
        (finished) => {
          if (finished) runOnJS(clearOverlay)();
        },
      );
      return true;
    },
    [clearOverlay, from, progress, to],
  );

  const handleRootLayout = useCallback((_e: LayoutChangeEvent) => {
    rootRef.current?.measureInWindow((x, y) => {
      originRef.current = { x, y };
    });
  }, []);

  const overlayStyle = useAnimatedStyle(() => {
    const f = from.value;
    const t = to.value;
    const p = progress.value;
    return {
      position: "absolute" as const,
      left: f.x + (t.x - f.x) * p,
      top: f.y + (t.y - f.y) * p,
      width: f.width + (t.width - f.width) * p,
      height: f.height + (t.height - f.height) * p,
    };
  });

  const contextValue = useMemo<MatchedGeometryContextValue>(
    () => ({ registerRect, requestTransition, originRef }),
    [registerRect, requestTransition],
  );

  return (
    <MatchedGeometryContext.Provider value={contextValue}>
      <View
        ref={rootRef}
        onLayout={handleRootLayout}
        className={cn("flex-1", className)}
        style={style}
        {...props}
      >
        {children}
        {overlayNode ? (
          <Animated.View style={overlayStyle} pointerEvents="none">
            {overlayNode}
          </Animated.View>
        ) : null}
      </View>
    </MatchedGeometryContext.Provider>
  );
}

export interface UseMatchedGeometryResult {
  ref: React.RefObject<View | null>;
  onLayout: () => void;
  animatedStyle: AnimatedStyle<{ opacity: number }>;
}

/**
 * Registers `content` under `id`. The first layout after a *different*
 * previously-registered rect exists for the same `id` triggers a morph
 * overlay from that old rect to this one; the first-ever mount for an `id`
 * just appears (nothing to morph from yet).
 */
export function useMatchedGeometry(
  id: string,
  content: React.ReactNode,
  duration: number = DEFAULT_TRANSITION_DURATION,
): UseMatchedGeometryResult {
  const ctx = useContext(MatchedGeometryContext);
  if (!ctx) {
    throw new Error(
      "useMatchedGeometry must be used within a MatchedGeometryProvider",
    );
  }
  const ref = useRef<View>(null);
  const opacity = useSharedValue(1);
  const hasLaidOut = useRef(false);

  const onLayout = useCallback(() => {
    ref.current?.measureInWindow((x, y, width, height) => {
      const rect: Rect = {
        x: x - ctx.originRef.current.x,
        y: y - ctx.originRef.current.y,
        width,
        height,
      };
      if (hasLaidOut.current) {
        ctx.registerRect(id, rect);
        return;
      }
      hasLaidOut.current = true;
      const didTransition = ctx.requestTransition(id, rect, content, duration);
      if (didTransition) {
        opacity.value = 0;
        opacity.value = withTiming(1, {
          duration,
          easing: TRANSITION_EASING,
        });
      } else {
        ctx.registerRect(id, rect);
      }
    });
  }, [content, ctx, duration, id, opacity]);

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));

  return { ref, onLayout, animatedStyle };
}

export interface MatchedGeometryViewProps
  extends React.ComponentPropsWithoutRef<typeof View> {
  id: string;
  duration?: number;
  className?: string;
}

/** Drop-in wrapper around `useMatchedGeometry` — tag two of these with the same `id` across a mount/unmount swap to get a shared-element morph for free. */
export function MatchedGeometryView({
  id,
  duration,
  className,
  style,
  children,
  ...props
}: MatchedGeometryViewProps) {
  const { ref, onLayout, animatedStyle } = useMatchedGeometry(
    id,
    children,
    duration,
  );
  return (
    <Animated.View
      ref={ref}
      onLayout={onLayout}
      style={[animatedStyle, style]}
      className={cn(className)}
      {...props}
    >
      {children}
    </Animated.View>
  );
}