Skip to main content
The SharedXImage preset creates an X (Twitter)-style shared element transition where an image slides in from the top or bottom based on drag direction, with a morphing thumbnail-to-fullscreen effect.
This preset requires @react-native-masked-view/masked-view. See Masked View Setup for installation.

Function Signature

SharedXImage(
  config: Partial<ScreenTransitionConfig> & {
    sharedBoundTag: string;
  }
): ScreenTransitionConfig

Parameters

config
object
required
Configuration object with required sharedBoundTag

Returns

ScreenTransitionConfig
object
Configuration with vertical drag, dynamic entry direction, and bound morphing

Implementation Details

Transition Spec

transitionSpec: {
  open: {
    stiffness: 1000,
    damping: 500,
    mass: 3,
    overshootClamping: true,
    restSpeedThreshold: 0.02,
  },
  close: {
    stiffness: 1000,
    damping: 500,
    mass: 3,
    overshootClamping: true,
    restSpeedThreshold: 0.02,
  },
}

Screen Style Interpolator

The preset only animates the focused screen (unfocused screens remain static):
screenStyleInterpolator: ({
  focused,
  bounds,
  current,
  layouts: { screen },
  progress,
}) => {
  "worklet";

  // Twitter doesn't animate the unfocused screen
  if (!focused) return {};

  const boundValues = bounds({
    id: sharedBoundTag,
    method: "transform",
    raw: true,
  });

  // Content styles
  const dragY = interpolate(
    current.gesture.normalizedY,
    [-1, 0, 1],
    [-screen.height, 0, screen.height],
  );

  // Dynamically changes direction based on the drag direction
  const contentY = interpolate(
    progress,
    [0, 1],
    [dragY >= 0 ? screen.height : -screen.height, 0],
  );

  const overlayClr = interpolateColor(
    current.progress - Math.abs(current.gesture.normalizedY),
    [0, 1],
    ["rgba(0,0,0,0)", "rgba(0,0,0,1)"],
  );

  const borderRadius = interpolate(current.progress, [0, 1], [12, 0]);

  // Bound styles - only enter animation
  const x = !current.closing ? boundValues.translateX : 0;
  const y = !current.closing ? boundValues.translateY : 0;
  const scaleX = !current.closing ? boundValues.scaleX : 1;
  const scaleY = !current.closing ? boundValues.scaleY : 1;

  return {
    [sharedBoundTag]: {
      transform: [
        { translateX: x },
        { translateY: y },
        { scaleX: scaleX },
        { scaleY: scaleY },
      ],
      borderRadius,
      overflow: "hidden",
    },
    contentStyle: {
      transform: [{ translateY: contentY }, { translateY: dragY }],
      pointerEvents: current.animating ? "none" : "auto",
    },
    backdropStyle: {
      backgroundColor: overlayClr,
    },
  };
}
Key behaviors:
  1. Dynamic Entry Direction
    // If dragY >= 0 (swiping down), enter from bottom
    // If dragY < 0 (swiping up), enter from top
    const contentY = interpolate(
      progress,
      [0, 1],
      [dragY >= 0 ? screen.height : -screen.height, 0],
    );
    
  2. Bound Morphing Only on Open
    // Bounds only animate during opening (not closing)
    const x = !current.closing ? boundValues.translateX : 0;
    const y = !current.closing ? boundValues.translateY : 0;
    
  3. Drag-Responsive Overlay
    // Overlay fades out as you drag
    const overlayClr = interpolateColor(
      current.progress - Math.abs(current.gesture.normalizedY),
      [0, 1],
      ["rgba(0,0,0,0)", "rgba(0,0,0,1)"],
    );
    
  4. Border Radius Animation
    // Animates from 12px (thumbnail) to 0px (fullscreen)
    const borderRadius = interpolate(current.progress, [0, 1], [12, 0]);
    

Usage Example

// app/timeline.tsx
import { router } from "expo-router";
import Transition from "react-native-screen-transitions";

export default function TimelineScreen() {
  return (
    <Transition.Pressable
      sharedBoundTag="tweet-image"
      style={{
        width: 300,
        height: 200,
        borderRadius: 12,
        overflow: "hidden",
      }}
      onPress={() => {
        router.push({
          pathname: "/image",
          params: { sharedBoundTag: "tweet-image" },
        });
      }}
    >
      <Image
        source={{ uri: "https://example.com/image.jpg" }}
        style={{ width: "100%", height: "100%" }}
      />
    </Transition.Pressable>
  );
}

Key Features

  • Dynamic entry direction - Enters from top or bottom based on initial drag
  • Vertical-only gestures - Swipe up or down to dismiss
  • Asymmetric animation - Bounds morph on open, simple slide on close
  • Drag-responsive overlay - Backdrop fades as you drag
  • Border radius animation - Smooth corner transition
  • No masking required - Uses standard View, not MaskedView (simpler setup)

Comparison with Other Shared Presets

FeatureSharedXImageSharedIGImageSharedAppleMusic
Entry directionDynamic (drag-based)Always from sourceAlways from source
Exit animationSimple slideBounds morphBounds morph
GesturesVertical onlyMulti-directionalMulti-directional
ShadowsNoNoYes (platform-specific)
MaskingNoYesYes
Background animationNoneNoneScale down

Notes

Simpler than other shared presets: This preset doesn’t require Transition.MaskedView because it only animates bounds on open, not close. The close animation is a simple slide.
The entry direction adapts to your initial swipe. If you swipe down to dismiss, the next time you open it, it enters from the bottom. This creates a natural, predictable feel.
The overlay color calculation subtracts the drag distance from progress, so the backdrop fades out as you drag. This can create a transparent state during active dragging.