StealThis .dev

React Native Shared Element Transition

A shared element transition effect for React Native that animates position and size of an element between two views using measure and Animated API.

react-native typescript
Targets: React Native

Expo Snack

Code

import React, {
  createContext,
  useContext,
  useRef,
  useState,
  useCallback,
  useMemo,
  type ReactNode,
} from "react";
import {
  View,
  Text,
  Image,
  TouchableOpacity,
  Animated,
  StyleSheet,
  Dimensions,
  StatusBar,
  type LayoutChangeEvent,
  type ViewStyle,
} from "react-native";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

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

interface TransitionState {
  id: string;
  source: Measurement;
  dest: Measurement;
  animProgress: Animated.Value;
  opacity: Animated.Value;
  active: boolean;
}

interface SharedTransitionContextValue {
  register: (id: string, ref: React.RefObject<View>) => void;
  unregister: (id: string) => void;
  startTransition: (id: string, dest: Measurement) => void;
  reverseTransition: (id: string) => void;
  getTransition: (id: string) => TransitionState | undefined;
  isAnimating: boolean;
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

const SharedTransitionContext = createContext<SharedTransitionContextValue>({
  register: () => {},
  unregister: () => {},
  startTransition: () => {},
  reverseTransition: () => {},
  getTransition: () => undefined,
  isAnimating: false,
});

// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------

function SharedTransitionProvider({ children }: { children: ReactNode }) {
  const refs = useRef<Record<string, React.RefObject<View>>>({});
  const [transitions, setTransitions] = useState<Record<string, TransitionState>>({});
  const [isAnimating, setIsAnimating] = useState(false);

  const register = useCallback((id: string, ref: React.RefObject<View>) => {
    refs.current[id] = ref;
  }, []);

  const unregister = useCallback((id: string) => {
    delete refs.current[id];
  }, []);

  const measure = (ref: React.RefObject<View>): Promise<Measurement> =>
    new Promise((resolve) => {
      if (ref.current) {
        ref.current.measure((_x, _y, width, height, pageX, pageY) => {
          resolve({ x: pageX, y: pageY, width, height });
        });
      }
    });

  const startTransition = useCallback(async (id: string, dest: Measurement) => {
    const ref = refs.current[id];
    if (!ref) return;

    const source = await measure(ref);
    const animProgress = new Animated.Value(0);
    const opacity = new Animated.Value(0);

    const state: TransitionState = {
      id,
      source,
      dest,
      animProgress,
      opacity,
      active: true,
    };

    setTransitions((prev) => ({ ...prev, [id]: state }));
    setIsAnimating(true);

    Animated.parallel([
      Animated.spring(animProgress, {
        toValue: 1,
        tension: 60,
        friction: 9,
        useNativeDriver: false,
      }),
      Animated.timing(opacity, {
        toValue: 1,
        duration: 250,
        useNativeDriver: false,
      }),
    ]).start();
  }, []);

  const reverseTransition = useCallback((id: string) => {
    setTransitions((prev) => {
      const t = prev[id];
      if (!t) return prev;

      Animated.parallel([
        Animated.spring(t.animProgress, {
          toValue: 0,
          tension: 60,
          friction: 9,
          useNativeDriver: false,
        }),
        Animated.timing(t.opacity, {
          toValue: 0,
          duration: 200,
          useNativeDriver: false,
        }),
      ]).start(() => {
        setTransitions((p) => {
          const copy = { ...p };
          delete copy[id];
          return copy;
        });
        setIsAnimating(false);
      });

      return prev;
    });
  }, []);

  const getTransition = useCallback((id: string) => transitions[id], [transitions]);

  const value = useMemo(
    () => ({
      register,
      unregister,
      startTransition,
      reverseTransition,
      getTransition,
      isAnimating,
    }),
    [register, unregister, startTransition, reverseTransition, getTransition, isAnimating]
  );

  // Render the animated overlay clones
  const overlays = Object.values(transitions).map((t) => {
    const left = t.animProgress.interpolate({
      inputRange: [0, 1],
      outputRange: [t.source.x, t.dest.x],
    });
    const top = t.animProgress.interpolate({
      inputRange: [0, 1],
      outputRange: [t.source.y, t.dest.y],
    });
    const width = t.animProgress.interpolate({
      inputRange: [0, 1],
      outputRange: [t.source.width, t.dest.width],
    });
    const height = t.animProgress.interpolate({
      inputRange: [0, 1],
      outputRange: [t.source.height, t.dest.height],
    });

    return (
      <Animated.View
        key={t.id}
        pointerEvents="none"
        style={[
          styles.overlay,
          {
            left,
            top,
            width,
            height,
            opacity: t.opacity,
            borderRadius: t.animProgress.interpolate({
              inputRange: [0, 1],
              outputRange: [12, 0],
            }),
          },
        ]}
      >
        <Image
          source={{ uri: imageForId(t.id) }}
          style={StyleSheet.absoluteFill}
          resizeMode="cover"
        />
      </Animated.View>
    );
  });

  return (
    <SharedTransitionContext.Provider value={value}>
      {children}
      {overlays}
    </SharedTransitionContext.Provider>
  );
}

// ---------------------------------------------------------------------------
// SharedTransition wrapper
// ---------------------------------------------------------------------------

interface SharedTransitionProps {
  id: string;
  children: ReactNode;
  style?: ViewStyle;
}

function SharedTransition({ id, children, style }: SharedTransitionProps) {
  const viewRef = useRef<View>(null);
  const { register, unregister } = useContext(SharedTransitionContext);

  const handleLayout = useCallback(
    (_e: LayoutChangeEvent) => {
      register(id, viewRef);
      return () => unregister(id);
    },
    [id, register, unregister]
  );

  return (
    <View ref={viewRef} onLayout={handleLayout} style={style} collapsable={false}>
      {children}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Demo helpers
// ---------------------------------------------------------------------------

const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get("window");
const COLUMNS = 2;
const GAP = 12;
const THUMB_SIZE = (SCREEN_W - GAP * (COLUMNS + 1)) / COLUMNS;

interface ImageItem {
  id: number;
  uri: string;
}

const IMAGES: ImageItem[] = [
  { id: 1, uri: "https://picsum.photos/seed/st1/600/600" },
  { id: 2, uri: "https://picsum.photos/seed/st2/600/600" },
  { id: 3, uri: "https://picsum.photos/seed/st3/600/600" },
  { id: 4, uri: "https://picsum.photos/seed/st4/600/600" },
];

/** Resolve the image URI from a transition id like "image-1" */
function imageForId(transitionId: string): string {
  const numericId = Number(transitionId.replace("image-", ""));
  return IMAGES.find((i) => i.id === numericId)?.uri ?? IMAGES[0].uri;
}

// ---------------------------------------------------------------------------
// Grid view
// ---------------------------------------------------------------------------

function GridView({ onSelect }: { onSelect: (item: ImageItem) => void }) {
  return (
    <View style={styles.grid}>
      {IMAGES.map((item) => (
        <TouchableOpacity key={item.id} activeOpacity={0.8} onPress={() => onSelect(item)}>
          <SharedTransition id={`image-${item.id}`} style={styles.thumbWrapper}>
            <Image source={{ uri: item.uri }} style={styles.thumb} />
          </SharedTransition>
        </TouchableOpacity>
      ))}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Detail view
// ---------------------------------------------------------------------------

function DetailView({
  item,
  onBack,
}: {
  item: ImageItem;
  onBack: () => void;
}) {
  const fadeAnim = useRef(new Animated.Value(0)).current;

  React.useEffect(() => {
    Animated.timing(fadeAnim, {
      toValue: 1,
      duration: 300,
      useNativeDriver: true,
    }).start();
  }, [fadeAnim]);

  const handleBack = () => {
    Animated.timing(fadeAnim, {
      toValue: 0,
      duration: 200,
      useNativeDriver: true,
    }).start(() => onBack());
  };

  return (
    <Animated.View style={[styles.detail, { opacity: fadeAnim }]}>
      <Image source={{ uri: item.uri }} style={styles.detailImage} resizeMode="cover" />
      <View style={styles.detailContent}>
        <Text style={styles.detailTitle}>Photo #{item.id}</Text>
        <Text style={styles.detailDesc}>
          This is a full-screen detail view. The shared element transition animates the thumbnail
          from its grid position to the hero image above using measured coordinates and spring
          physics.
        </Text>
      </View>
      <TouchableOpacity style={styles.backButton} onPress={handleBack} activeOpacity={0.7}>
        <Text style={styles.backText}>← Back</Text>
      </TouchableOpacity>
    </Animated.View>
  );
}

// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------

export default function App() {
  const [selected, setSelected] = useState<ImageItem | null>(null);
  const { startTransition, reverseTransition } = useContext(SharedTransitionContext);

  const handleSelect = (item: ImageItem) => {
    const dest: Measurement = { x: 0, y: 0, width: SCREEN_W, height: SCREEN_W };
    startTransition(`image-${item.id}`, dest);
    setSelected(item);
  };

  const handleBack = () => {
    if (selected) {
      reverseTransition(`image-${selected.id}`);
    }
    setSelected(null);
  };

  return (
    <View style={styles.container}>
      <StatusBar barStyle="light-content" />
      {!selected ? (
        <>
          <Text style={styles.heading}>Shared Transitions</Text>
          <Text style={styles.subtitle}>Tap a photo to expand</Text>
          <GridView onSelect={handleSelect} />
        </>
      ) : (
        <DetailView item={selected} onBack={handleBack} />
      )}
    </View>
  );
}

// Wrap the default export so the provider is always present
const OriginalApp = App;
App = function WrappedApp() {
  return (
    <SharedTransitionProvider>
      <OriginalApp />
    </SharedTransitionProvider>
  );
};
export { App };

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0f172a",
    paddingTop: 60,
  },
  heading: {
    color: "#f8fafc",
    fontSize: 28,
    fontWeight: "700",
    marginHorizontal: GAP,
    marginBottom: 4,
  },
  subtitle: {
    color: "#94a3b8",
    fontSize: 15,
    marginHorizontal: GAP,
    marginBottom: 20,
  },
  grid: {
    flexDirection: "row",
    flexWrap: "wrap",
    paddingHorizontal: GAP,
    gap: GAP,
  },
  thumbWrapper: {
    width: THUMB_SIZE,
    height: THUMB_SIZE,
    borderRadius: 12,
    overflow: "hidden",
  },
  thumb: {
    width: "100%",
    height: "100%",
  },
  overlay: {
    position: "absolute",
    overflow: "hidden",
    zIndex: 999,
  },
  detail: {
    flex: 1,
  },
  detailImage: {
    width: SCREEN_W,
    height: SCREEN_W,
  },
  detailContent: {
    padding: 20,
  },
  detailTitle: {
    color: "#f8fafc",
    fontSize: 24,
    fontWeight: "700",
    marginBottom: 8,
  },
  detailDesc: {
    color: "#94a3b8",
    fontSize: 15,
    lineHeight: 22,
  },
  backButton: {
    position: "absolute",
    top: 12,
    left: 16,
    backgroundColor: "rgba(15,23,42,0.7)",
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 20,
  },
  backText: {
    color: "#f8fafc",
    fontSize: 16,
    fontWeight: "600",
  },
});

React Native Shared Element Transition

A smooth shared element transition system that animates an element’s position and size between two views. Tap an item in a list or grid and watch it morph seamlessly into a detail view, then reverse the animation on dismiss. Built entirely with React Native’s Animated API and ref.measure() — no external dependencies required.

Props

PropTypeRequiredDescription
idstringYesUnique identifier used to match shared elements across source and destination views. Elements with the same id will animate between each other.
childrenReactNodeYesThe content to render inside the shared element wrapper.

The SharedTransitionProvider must wrap your app or the portion of the tree that participates in transitions. It manages measuring, cloning, and animating elements between views.

Usage

import { SharedTransitionProvider, SharedTransition } from './SharedTransition';

// Wrap your app
<SharedTransitionProvider>
  <App />
</SharedTransitionProvider>

// Source view (e.g., grid thumbnail)
<SharedTransition id={`image-${item.id}`}>
  <Image source={{ uri: item.thumb }} style={styles.thumbnail} />
</SharedTransition>

// Destination view (e.g., detail screen)
<SharedTransition id={`image-${item.id}`}>
  <Image source={{ uri: item.full }} style={styles.hero} />
</SharedTransition>

How it works

  1. Measure phase — When a transition is triggered, ref.measure() captures the absolute position (pageX, pageY) and dimensions (width, height) of the source element on screen.
  2. Clone phase — An overlay clone is rendered at the exact source position using Animated.View with absolute positioning.
  3. Animate phase — The clone’s top, left, width, and height are driven by a spring animation toward the destination element’s measured layout. A simultaneous opacity crossfade hides the source and reveals the destination content.
  4. Settle phase — Once the animation completes, the overlay clone is removed and the destination element becomes fully visible in place.
  5. Reverse — Dismissing the detail view runs the same sequence in reverse, animating the element back to its original grid position.