← All writing

Implementation guide · Mobile systems

building continuous media transitions in React Native

A complete source-to-viewer handoff for images and live video: identity, measurement, presentation ownership, first-frame readiness, gesture arbitration, return targeting and visual proof.

I posted a short recording of a media viewer I built. Images and videos leave the feed, open into a fullscreen viewer, survive interrupted dismissal gestures, then return to the current feed rectangle without appearing to become a different object. A surprising number of the replies asked how it worked.

The short answer is that the animation is only the visible part of it.

Film 01

what the system has to preserve

Portrait and landscape video open from the feed and return without a visible restart; an image survives several cancelled downward drags before dismissal commits.
Read the film description

A portrait video leaves its feed frame and returns. An image recovers from several cancelled downward drags before a later dismissal commits. A landscape video then opens into a letterboxed viewer, plays, and returns without a visible restart.

It is tempting to describe this as a shared-element transition. Measure the thumbnail, open an overlay, animate a rectangle, done. That description is almost sufficient for an image. It stops being sufficient as soon as the rectangle contains a playing video.

The video has a clock. The feed can recycle its row. The fullscreen viewer has its own native surface. A downward drag can be cancelled halfway. The user can page to another item before closing. By then, “animate this rectangle” no longer says which video is moving, which view is drawing it, or where it should return.

So we will earn the architecture from the failure, one piece at a time.

Interaction contract

A feed containing images and videos; a horizontally paged fullscreen viewer; tap-to-open; downward interactive dismissal with cancellation; continued playback across the handoff; and a return animation that still works after the feed has moved or recycled the originating row.

Start with the version everyone writes first

Suppose a feed card owns an image or an Expo VideoView. Pressing it stores the item, opens a fullscreen layer and renders the same URI again:

Implementation specimentsx
function FeedCard({ item }: { item: MediaItem }) {
  return (
    <Pressable onPress={() => setOpenItem(item)}>
      {item.type === "image" ? (
        <Image source={{ uri: item.uri }} style={styles.media} />
      ) : (
        <VideoView player={inlinePlayer} style={styles.media} />
      )}
    </Pressable>
  );
}

function Viewer({ item }: { item: MediaItem }) {
  return item.type === "image" ? (
    <Image source={{ uri: item.uri }} style={styles.fullscreen} />
  ) : (
    <VideoView player={viewerPlayer} style={styles.fullscreen} />
  );
}

Add a measured source rectangle and a Reanimated shared value and the image can look convincing. The second image decodes the same asset while the first one is hidden. A still frame is forgiving.

Video exposes what the code actually did. viewerPlayer starts as a new playback session, so it may seek, buffer or restart. Reusing inlinePlayer does not automatically solve it: the fullscreen VideoView is still a different native surface, and readiness of the player does not prove that surface has drawn a frame. For a few milliseconds the feed and viewer can both appear, or neither can appear.

The first useful question is therefore not “which easing curve feels right?” It is:

At this exact moment, which native view is responsible for the pixels?

Everything that follows is an answer to that question.

Four facts that must not collapse into one

Take one video card called fox, paused at 12.4 seconds.

  1. Identity answers which media is this? fox needs a key that survives filtering, pagination and duplicate URLs.
  2. Playback answers what is the media doing? It includes the player, playhead, paused state, mute state and playback intent.
  3. Presentation answers which native view is drawing the pixels? The inline view, a moving overlay or the fullscreen view can take that job.
  4. Geometry answers where should those pixels be drawn? During a gesture, this is a live rectangle, not just the thumbnail measurement captured at tap time.

These facts often change at different times. During opening, identity remains fox; playback may continue past 12.4 seconds; presentation leaves the feed; geometry travels towards fullscreen. Treating them as one isOpen boolean throws away the distinctions needed to make the handoff reliable.

This is the smallest mental model worth keeping:

Implementation specimentext
same media identity
same playback session
one visible presentation
changing geometry

Figure 01

four facts travel through the same interaction

A feed video annotated with four independent facts: identity, playback, presentation and geometry.
Identity, playback, presentation and geometry describe different parts of the visible object. They remain related without becoming one piece of state.
Read the diagram description

A feed video sits at the centre. Four callouts separate its stable media identity, current playback state, active presentation surface and measured geometry.

One object on screen, three places it can live

The interaction should feel as though one object moves through space. In the view hierarchy, that object needs three places to appear:

Implementation specimentext
SourceSurface  →  TransitionSurface  →  ViewerSurface
     feed              temporary           steady state

The source is its place in the feed. The transition surface is a short-lived place above the feed where it can move without being clipped by a row or scroll view. The viewer is its settled place inside the fullscreen pager.

Figure 02

Teleport moves the same native view between hosts

The same native VideoView, identified as VideoView 42, is reparented by React Native Teleport between inline, root overlay and viewer hosts while the same VideoPlayer session continues.
One identified VideoView is reparented from the inline host to the root overlay host and then the viewer host. Its VideoPlayer session remains uninterrupted beneath the move.
Read the diagram description

Three empty PortalHost destinations sit along one dotted route. The same native VideoView travels that route through React Native Teleport while one player timeline continues below it.

For an image, the transport can be another URI-backed image or a native snapshot. For video, the choice is harder. You can move a live native surface, attach the same player to another surface, or transport a poster while the destination surface prepares. Each strategy transfers different things.

The first type should therefore represent identity, not a component:

Implementation speciments
export type MediaKey = {
  feedItemId: string;
  mediaIndex: number;
  assetId: string;
};

export type MediaItem =
  | { key: MediaKey; type: "image"; uri: string; aspectRatio: number }
  | {
      key: MediaKey;
      type: "video";
      uri: string;
      posterUri?: string;
      aspectRatio: number;
    };

export function sameMedia(a: MediaKey | null, b: MediaKey | null) {
  return (
    a?.feedItemId === b?.feedItemId &&
    a?.mediaIndex === b?.mediaIndex &&
    a?.assetId === b?.assetId
  );
}

Do not use the URI as identity. Two feed items can point at the same asset. A single post can contain several derived URLs for one video. URLs can change after cache or CDN transformations. Array index alone also fails once the feed prepends, filters or reorders data.

Follow one opening before naming the machinery

Return to fox. At rest, its inline view draws the pixels. The user taps it.

  1. Measure the feed rectangle while the feed still presents the video.
  2. Put the visible media in a root overlay at exactly that rectangle.
  3. Only after the overlay is visible, stop the feed from presenting.
  4. Animate the overlay towards the contained fullscreen rectangle.
  5. Prepare the viewer at the destination.
  6. Give the viewer presentation only when it can show the expected media.
  7. Remove the temporary overlay.

Notice the ordering in steps 2 and 3. Hiding the feed first produces a blank frame. Showing the overlay first without hiding the feed produces two copies. The transfer needs a brief structural overlap but only one unmasked visual owner.

Closing runs the same sequence in reverse, except that the feed rectangle must be measured again. The user may have scrolled, the list may have recycled the row, or the active gallery item may have changed. The opening rectangle is a memory, not a destination.

A cancelled drag is simpler: do not run the close sequence at all. Spring the same viewer back into place. Keep its page, player and surface alive.

We can now name the rule the interaction has been following:

Source, transport and viewer may all exist, but only one owns visible presentation at a time.

Figure 03

the complete interaction is a sequence of handoffs

Six-stage media continuity lifecycle from source through measurement, Teleport reparenting, settled viewer and drag release; cancel returns to the same viewer while commit reveals, paints and measures a fresh return rectangle.
Source, measurement, Teleport reparenting and settlement establish the viewer. Cancellation loops back without a remount; commitment reveals, paints and measures a fresh return rectangle.
Read the diagram description

Six numbered stages show the source being measured, teleported to the root overlay, settled, dragged and returned. The teal branch restores the same viewer; the orange branch resolves the current target before closing.

Why React Native Teleport is the hinge

There is still a physical problem. The inline video lives beneath a virtualised row and a scroll view. The transition needs to live above them. zIndex cannot lift a native view out of ancestors that clip it.

React Native Teleport's Portal solves that specific problem. It can reparent the existing native view: the same view gets a new native parent without React unmounting it and creating another one.

That needs a precise distinction:

  • a conventional JavaScript portal renders equivalent React content somewhere else;
  • React Native Teleport keeps the component in its original React tree while physically moving its native view to another PortalHost;
  • component state, React context, animation state and—when the transported component is the video surface itself—its attachment to the player can survive the move.

Think of moving a television from one stand to another without turning it off. The programme is the player; the television is the native video surface; the stands are Teleport hosts. Teleport moves the television. It does not choose the programme, calculate the route or decide when the next stand is ready.

The minimum infrastructure is a provider and named hosts:

Implementation specimentsx
import {
  Portal,
  PortalHost,
  PortalProvider,
} from "react-native-teleport";

export function AppRoot() {
  return (
    <PortalProvider>
      <Navigation />
      <PortalHost
        name="media-overlay"
        style={StyleSheet.absoluteFill}
      />
    </PortalProvider>
  );
}

The active media surface stays wrapped by one Portal. Changing hostName changes its native parent:

Implementation specimentsx
function RoutedMediaSurface({
  destination,
  children,
}: {
  destination: "inline" | "overlay" | "viewer";
  children: React.ReactNode;
}) {
  const hostName =
    destination === "overlay"
      ? "media-overlay"
      : destination === "viewer"
        ? "media-viewer"
        : undefined;

  return <Portal hostName={hostName}>{children}</Portal>;
}

undefined leaves the surface in place inside the feed. "media-overlay" moves it to a root host where row clipping no longer applies. "media-viewer" hands it to a host mounted by the active fullscreen page. Closing reverses the route.

Implementation specimentext
inline parent
→ root overlay host
→ active viewer host
→ root overlay host
→ inline parent

The outer feed slot must remain mounted and retain its measured size while its child is away. Otherwise the list reflows at the same moment the opening geometry is supposed to remain stable.

Teleport does not decide media identity, measure the source, animate the rectangle, arbitrate gestures, preserve a VideoPlayer, prove the first visible frame or find a recycled return target. It only makes the ownership transfer physically possible without recreating the transported native view. The coordinator decides when each host may own presentation.

For images, you may still choose a separate URI-backed bridge rather than move the original image view. For video, moving the same VideoView is more valuable because the surface can remain attached to the same app-owned player. Platform constraints can still force a poster bridge or a second surface attached to that player. Teleport is therefore a transport primitive, not a promise that every platform should use the same transport plan.

There are two operational constraints worth knowing before adopting it. React Native Teleport requires React Native's New Architecture, and because it contains native code it needs an Expo development build rather than Expo Go. A host also belongs to a particular native window. If the viewer uses a native modal or dialog, mount the destination PortalHost inside that modal’s window instead of assuming the app-root host can appear above it.

The examples from here use React Native, React Native Teleport, Expo Video, React Native Reanimated, React Native Gesture Handler and React Native Pager View. The same ownership model can use different tools, but a replacement for Teleport must preserve the native-view continuity the interaction depends on.

Turn the sequence into a state machine

You do not need a state-machine library, but you do need state-machine semantics. We have already described states in plain language: measuring, opening, waiting for the viewer, settled, dragging, cancelling and closing. Giving them names prevents impossible combinations.

Distributed booleans such as isOpen, isClosing, viewerReady and showPoster become dangerous when an asynchronous measurement or native callback arrives after the active item changes.

A useful phase model is:

Implementation speciments
export type ContinuityPhase =
  | "idle"
  | "measuringSource"
  | "opening"
  | "preparingViewer"
  | "settled"
  | "dragging"
  | "cancelling"
  | "resolvingReturnTarget"
  | "closing"
  | "handingBack"
  | "failed";

export type PresentationOwner =
  | "source"
  | "transport"
  | "viewer"
  | "none";

export type ContinuitySession = {
  token: number;
  key: MediaKey;
  phase: ContinuityPhase;
  owner: PresentationOwner;
  sourceFrame: MeasuredFrame | null;
  returnFrame: MeasuredFrame | null;
};

Every opening receives a monotonically increasing token. The token travels through measurements, player callbacks, first-frame events and return-target requests:

Implementation speciments
const token = coordinator.beginOpen(item.key);
const source = await registry.measure(item.key);

if (!coordinator.matches(token, item.key)) return;
if (!source) return coordinator.fail(token, "source-unavailable");

coordinator.commitSourceMeasurement(token, source);

This is not defensive ceremony. A user can tap item A, close it, scroll and tap item B before item A's measurement or video callback returns. Without both token and identity checks, stale work can acquire presentation authority inside the new session.

The ownership table is the core of the design:

PhasePresentation ownerPlayback ownerGeometry authority
IdleSourceInline sessionSource registry
MeasuringSourceInline sessionSource registry + token
OpeningTransportExisting player sessionOpening plan
Preparing viewerTransport or posterExisting player sessionViewer stage
SettledViewerActive viewer pageViewer
DraggingViewer/drag stageViewer sessionGesture shared values
CancellingViewer/drag stageViewer sessionCancellation spring
Resolving returnViewerViewer sessionFresh registry request
ClosingTransportViewer session until handbackLocked return plan
Handing backSourceInline sessionCurrent source

Temporary overlap can exist in the native hierarchy. What matters is that visibility policy still declares one presentation owner. A destination VideoView may be mounted beneath a poster while it prepares; it does not own presentation until the expected surface produces the expected media frame.

One complete transaction, without hand-waving

It is easier to see the machinery when we stop speaking in nouns. Take one landscape video in the feed and follow a single opening all the way home.

The row is already registered under feed-item-42:0:asset-fox. Its layout slot owns presentation and contains a named inline Teleport host. The application-owned player is playing into one VideoView; that view is not owned by the row's React lifetime. It is rendered higher in the tree and routed into the row's host.

The user taps. The coordinator allocates token 81, then asks the registry to measure that full media key in window coordinates. If the measurement comes back after token 82 has started, it becomes a historical curiosity and nothing more. For token 81, the measurement is current, so the coordinator locks an opening plan from that rectangle to the viewer's contained rectangle.

Now the visible handoff begins:

Implementation specimentext
81  OPEN_REQUESTED             owner=source
81  SOURCE_MEASURED            key=feed-item-42:0:asset-fox
81  TRANSPORT_MOUNTED          host=media-overlay
81  SOURCE_PRESENTATION_LEFT   owner=transport
81  OPEN_STARTED               surface=teleported-video-surface
81  VIEWER_HOST_READY          host=media-viewer
81  VIEWER_OWNS_PRESENTATION   owner=viewer

TRANSPORT_MOUNTED is the important seam. The overlay host exists at the source rectangle before the inline presentation yields. Then the same VideoView is reparented from the named inline host to the root overlay host. The feed slot remains in layout, but its pixels are away. The overlay host—not the list row—animates towards the viewer rectangle.

At the end of that geometry animation, the active pager page has mounted its viewer host. Teleport reparents the same native video view again:

Implementation specimentext
media-inline:feed-item-42:0:asset-fox
→ media-overlay
→ media-viewer

There is no second Android VideoView competing for the player and no new player waiting to seek to the old time. If your platform path substitutes a different destination surface, this is precisely where the poster must remain until that expected surface emits its first visible frame. Do not borrow the same-surface exemption for a two-surface implementation.

Suppose the user drags down and changes their mind. The viewer enters dragging, the media rectangle and scrim follow shared values, and release policy returns cancel. The existing values spring back to zero. The pager does not remount, the player is not reacquired and no close cleanup runs. Only when the spring finishes does the session become settled again.

On the next drag, release policy commits. The viewer still owns presentation while the registry reveals feed-item-42, asks its current gallery to show media index 0, waits for layout, then measures feed-item-42:0:asset-fox again. That new rectangle may have nothing in common with the opening rectangle. Once the identity and measurement version pass, the coordinator locks the return plan, moves the one video surface from the viewer host to the overlay host and begins from the current drag geometry, not the old settled viewer rectangle.

At the final frame, the inline host is known to exist. Teleport routes the surface home, the source takes presentation ownership, and only then is the transaction released:

Implementation specimentext
81  DISMISS_COMMITTED
81  RETURN_REVEAL_REQUESTED
81  RETURN_TARGET_MEASURED     version=19
81  CLOSE_STARTED              owner=transport
81  SOURCE_OWNS_PRESENTATION   host=media-inline:feed-item-42:0:asset-fox
81  SESSION_RELEASED

That is the whole trick in operational form. The curves can change. Those ordering constraints cannot.

Register sources without making feed rows expensive

A production feed is virtualised. Rows mount, unmount and can be recycled for different items. Keeping a React ref in the viewer is insufficient because that ref can become stale before dismissal.

Use a registry keyed by media identity:

Implementation speciments
export type Rect = {
  x: number;
  y: number;
  width: number;
  height: number;
};

export type MeasuredFrame = {
  key: MediaKey;
  rect: Rect;
  version: number;
  measuredAt: number;
};

export interface MeasurementRegistry {
  register(key: MediaKey, ref: React.RefObject<View | null>): () => void;
  measure(key: MediaKey): Promise<MeasuredFrame | null>;
  reveal(key: MediaKey): Promise<void>;
}

The row registers its native host, not the viewer:

Implementation speciments
function FeedMedia({ item }: { item: MediaItem }) {
  const hostRef = useRef<View>(null);

  useEffect(
    () => measurementRegistry.register(item.key, hostRef),
    [item.key.feedItemId, item.key.mediaIndex, item.key.assetId],
  );

  return (
    <View ref={hostRef} collapsable={false}>
      <InlineMedia item={item} />
    </View>
  );
}

collapsable={false} matters when a native measurement depends on that view surviving React Native's view flattening.

Use React Native's measureInWindow so the transport and fullscreen overlay share one coordinate space:

Implementation speciments
function measureInWindowAsync(ref: React.RefObject<View | null>) {
  return new Promise<Rect | null>((resolve) => {
    ref.current?.measureInWindow((x, y, width, height) => {
      if (width <= 0 || height <= 0) return resolve(null);
      resolve({ x, y, width, height });
    });
  });
}

Do not hide platform offsets inside animation math. Normalize them once at the measurement boundary and record which coordinate space the resulting rectangle uses.

The registry should not live in every row's global-store subscription. Rows register and unregister their refs. The coordinator subscribes to the active session. A recycled list already has enough work; it should not rerender its visible rows at 60 or 120 updates per second because one media item is being dragged elsewhere.

Plan geometry before mounting the viewer

Opening needs a source rectangle and a destination rectangle. The destination is the largest rectangle that preserves the media aspect ratio inside the viewer stage:

Implementation speciments
export function containRect(
  stage: Rect,
  mediaAspectRatio: number,
): Rect {
  const stageRatio = stage.width / stage.height;

  if (mediaAspectRatio > stageRatio) {
    const height = stage.width / mediaAspectRatio;
    return {
      x: stage.x,
      y: stage.y + (stage.height - height) / 2,
      width: stage.width,
      height,
    };
  }

  const width = stage.height * mediaAspectRatio;
  return {
    x: stage.x + (stage.width - width) / 2,
    y: stage.y,
    width,
    height: stage.height,
  };
}

Interpolate edges or centers, but do it consistently. Center interpolation is easy to reason about:

Implementation speciments
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

export function interpolateRect(from: Rect, to: Rect, t: number): Rect {
  const fromCx = from.x + from.width / 2;
  const fromCy = from.y + from.height / 2;
  const toCx = to.x + to.width / 2;
  const toCy = to.y + to.height / 2;

  const width = lerp(from.width, to.width, t);
  const height = lerp(from.height, to.height, t);
  const cx = lerp(fromCx, toCx, t);
  const cy = lerp(fromCy, toCy, t);

  return { x: cx - width / 2, y: cy - height / 2, width, height };
}

The transport owns these values during opening. The source should not continue showing identical pixels underneath it. Keep the row's layout slot, but make its presentation transparent only after the transport has mounted with a valid source.

Image transport can be simple

For images, mount a new URI-backed transition surface in the root overlay. It does not need to be the original native image view. It needs the same source, crop, recycling key and aspect ratio.

The opening order is:

Implementation specimentext
measure source
→ mount transition image at source rect
→ hide inline image pixels
→ animate transition image to viewer rect
→ mount/confirm viewer image
→ hide transition image
→ viewer owns presentation

Closing performs the inverse. During an interactive drag, the active viewer surface can itself become the drag stage. Once dismissal commits and a valid return target exists, a dedicated return bridge gives you tighter control over crop, corner radius and final handback.

The backdrop should derive from the same interaction progress but remain a separate animated surface:

Implementation speciments
const visualProgress = Math.min(1, Math.max(0, dragY / commitDistance));

const mediaScale = interpolate(visualProgress, [0, 1], [1, targetScale]);
const scrimOpacity = interpolate(visualProgress, [0, 1], [1, 0]);
const radius = interpolate(visualProgress, [0, 1], [0, targetRadius]);

Keeping the backdrop separate prevents a late media measurement from changing the perceived response of the whole screen.

Video is a player plus a presentation surface

Expo Video's player is not the pixels. On iOS, AVPlayer is a nonvisual playback object; an AVPlayerLayer or player view controller presents it. On Android, the player renders into a SurfaceView or TextureView.

This is the same separation we used earlier, now expressed in platform terms. The player owns the timeline; the surface owns the visible frame. You can keep hearing audio from a healthy player while its surface is black, detached or covered by a poster. Conversely, a poster can look like paused video while no player is ready underneath it.

That explains why “the player never stopped” is not proof of continuity. The handoff must preserve playback and keep one valid presentation visible.

This distinction is the reason a correct image transition can still fail for video. Playback may continue while the visible surface disappears. A poster can remain above a healthy player. Two surfaces can briefly compete for the same player. A readiness event can arrive before the destination has shown a frame.

Do not create the continuity player with useVideoPlayer inside each inline and fullscreen component. That ties player lifetime to component lifetime, which is exactly what the handoff is crossing. Expo exposes createVideoPlayer for advanced cases where the application owns disposal.

A small registry can own one active continuity session:

Implementation speciments
type PlaybackSnapshot = {
  currentTime: number;
  playing: boolean;
  muted: boolean;
  capturedAt: number;
};

type VideoSession = {
  id: number;
  key: MediaKey;
  player: VideoPlayer;
  owner: "inline" | "viewer";
  firstVisibleSurfaceId: string | null;
  intent: PlaybackSnapshot;
};

class VideoSessionRegistry {
  private active: VideoSession | null = null;
  private nextId = 1;

  acquire(item: Extract<MediaItem, { type: "video" }>) {
    if (this.active && sameMedia(this.active.key, item.key)) {
      return this.active;
    }

    this.release();

    const player = createVideoPlayer(item.uri);
    player.loop = true;

    this.active = {
      id: this.nextId++,
      key: item.key,
      player,
      owner: "inline",
      firstVisibleSurfaceId: null,
      intent: {
        currentTime: 0,
        playing: false,
        muted: true,
        capturedAt: Date.now(),
      },
    };

    return this.active;
  }

  release() {
    this.active?.player.release();
    this.active = null;
  }
}

Production code also needs listener cleanup, source-replacement queues, backgrounding, native-object invalidation and stale-session checks. Keep those inside the registry rather than duplicating them between inline and viewer components.

Do not release the poster on readyToPlay

There are at least six different facts:

Implementation speciments
type VideoPresentationState = {
  playerReady: boolean;
  destinationSurfaceMounted: boolean;
  destinationFirstFrameVisible: boolean;
  posterVisible: boolean;
  playbackIntent: "playing" | "paused";
  actuallyPlaying: boolean;
};

They must not collapse into one isReady boolean.

readyToPlay says the player can begin. A time update says the playhead moved. Neither proves that the destination surface has displayed a frame. Expo Video's onFirstFrameRender exists for the point at which the mounted player has rendered into that VideoView.

Gate it by both surface and media identity:

Implementation specimentsx
function ViewerVideo({
  session,
  surfaceId,
  posterUri,
}: {
  session: VideoSession;
  surfaceId: string;
  posterUri?: string;
}) {
  const [firstFrameVisible, setFirstFrameVisible] = useState(false);

  return (
    <View style={StyleSheet.absoluteFill}>
      <VideoView
        player={session.player}
        style={StyleSheet.absoluteFill}
        contentFit="contain"
        nativeControls={false}
        surfaceType="textureView"
        onFirstFrameRender={() => {
          if (!videoRegistry.matches(session.id, session.key, surfaceId)) return;
          videoRegistry.markVisible(session.id, session.key, surfaceId);
          setFirstFrameVisible(true);
        }}
      />

      {!firstFrameVisible && posterUri ? (
        <Image
          source={{ uri: posterUri }}
          style={StyleSheet.absoluteFill}
          resizeMode="contain"
        />
      ) : null}
    </View>
  );
}

Reset the frame flag when the video track or expected surface changes. Expo documents that the callback can occur again after a track switch.

A playhead-based timeout can prevent a poster from remaining forever after a lost callback, but treat it as a recovery heuristic. It is not evidence that the viewer displayed the correct pixels.

Figure 04

player ready is not first visible frame

Five-stage readiness timeline in which the poster covers player readiness, destination surface mounting and session, media-key and surface-ID validation; stale callbacks are ignored and poster release follows the first visible frame.
The poster covers player readiness, destination mounting and identity validation. It is released only after the expected session, media key and surface ID produce a visible frame.
Read the diagram description

A five-stage timeline separates player readiness, destination mounting, identity matching, first-frame proof and poster release. Stale or mismatched callbacks leave the gate through an ignore path.

iOS: move or reattach the player layer deliberately

The production iOS path required native work because ordinary component lifecycle did not provide the surface control needed for a reliable handoff. The reusable mechanism is:

  1. Use an AVPlayerLayer when the application owns controls.
  2. Retain the same AVPlayer.
  3. Attach it to a lifted, window-level transition view.
  4. Animate that transition view between measured rectangles.
  5. Observe the destination layer's isReadyForDisplay.
  6. Hide or detach the previous surface only under the ownership policy.
  7. Restore the inline attachment after return.

Apple defines AVPlayerLayer.isReadyForDisplay as the point at which the first frame of the current item is ready for display. It is observable, which makes it suitable for a native-to-JavaScript first-visible-frame event.

Keep the patched native surface behind a narrow adapter:

Implementation speciments
export interface NativeVideoTransitionAdapter {
  canMoveLiveSurface(): boolean;
  open(input: {
    sessionId: number;
    source: Rect;
    destination: Rect;
    cornerRadius: number;
  }): Promise<void>;
  close(input: {
    sessionId: number;
    source: Rect;
    destination: Rect;
    cornerRadius: number;
  }): Promise<void>;
  cancel(sessionId: number): Promise<void>;
}

Do not let the rest of the viewer call arbitrary native transition methods. The coordinator decides ownership; the platform adapter carries out one bounded surface operation.

When maintaining a patch against Expo Video, record the exact upstream version, assert the expected original file before applying the patch, and fail the build when the target changes. Full-file native replacements that silently apply to a new Expo release are an invitation to undefined behaviour.

Android: choose the surface model for the interaction

Expo Video defaults to surfaceView. Android's Media3 surface guidance recommends SurfaceView for ordinary playback because it generally uses less power, offers better frame timing and supports capabilities such as secure output.

This viewer is not ordinary playback. The surface participates in clipping, rounded corners, overlays, paging and animated geometry. TextureView lives inside the normal view hierarchy, which makes it the practical choice when a video must overlap or transform with React Native content.

That trade is explicit:

SurfacePrefer whenCost or limitation
SurfaceViewStable, non-overlapping playbackSeparate surface composition complicates clipping, z-order and some transforms
TextureViewAnimated, clipped or overlapping viewer surfacesHigher power cost and fewer specialised output capabilities

Set the surfaceType when mounting the view. Expo warns against changing it at runtime:

Implementation specimentsx
<VideoView
  player={session.player}
  surfaceType="textureView"
  useExoShutter={false}
  nativeControls={false}
/>

Disabling useExoShutter prevents the default ExoPlayer shutter from becoming an unplanned visual owner before the first frame. Your own poster policy then controls what covers the destination surface.

Expo also documents an Android limitation: mounting several VideoView instances with the same player does not work as a general handoff technique. Keep the active surface count bounded. During motion, prefer one routed surface or a poster transport rather than assuming two live views can safely share the player.

Do not force iOS and Android through the same visual implementation merely to keep the component tree symmetrical. They can share the ownership contract while using different transports.

Film 02

landscape video makes a dishonest handoff obvious

The same player state survives feed, letterboxed viewer and return. The black bars remain part of the viewer’s interaction surface.
Read the film description

The feed scrolls to a landscape video, opens it into a letterboxed fullscreen viewer, begins playback, and closes it to the current feed rectangle. Controls and play state remain coherent across the return.

The settled viewer owns paging

Once opening settles, transport should stop being the architecture. The fullscreen pager owns page identity, adjacent rendering and horizontal interaction.

Render the active page and a small adjacent window. Only the active video page may acquire the playback session:

Implementation specimentsx
function GalleryPage({
  item,
  active,
}: {
  item: MediaItem;
  active: boolean;
}) {
  if (item.type === "image") {
    return <ViewerImage item={item} />;
  }

  return active ? (
    <ActiveViewerVideo item={item} />
  ) : (
    <VideoPoster item={item} />
  );
}

When the pager selects a new page:

  1. Update the active MediaKey.
  2. Pause the previous video by identity.
  3. Prepare or replace the player source through the registry.
  4. Reset the expected surface and first-frame state.
  5. Keep the new poster until that surface reports its frame.

Do not allow an inactive page's delayed callback to mark the active page ready. The same session, token, key and surface checks used during opening apply here.

The user drags the viewer, not the bitmap

A portrait image fills most of a portrait screen. A landscape video does not. If the downward dismiss gesture wraps only the media rectangle, letterboxed space becomes dead even though the user still perceives it as part of the viewer.

Wrap the full viewport, including the pager:

Implementation specimentsx
<GestureDetector gesture={viewerGesture}>
  <AnimatedPagerView style={StyleSheet.absoluteFill}>
    {pages}
  </AnimatedPagerView>
</GestureDetector>

Horizontal paging and vertical dismissal begin from similar touch streams. A generic Pan that activates automatically will sometimes steal the pager. Use manual activation to classify intent:

Implementation speciments
const verticalLead = 1.2;
const activateY = 8;
const failX = 14;

const dismissPan = Gesture.Pan()
  .manualActivation(true)
  .maxPointers(1)
  .shouldCancelWhenOutside(false)
  .onTouchesMove((event, state) => {
    const touch = event.allTouches[0];
    if (!touch) return;

    const dx = touch.absoluteX - startX.value;
    const dy = touch.absoluteY - startY.value;

    if (dy < 0 && Math.abs(dy) > Math.abs(dx)) {
      state.fail();
      return;
    }

    if (Math.abs(dx) >= failX && Math.abs(dx) > Math.abs(dy) * verticalLead) {
      state.fail(); // native pager wins
      return;
    }

    if (dy >= activateY && dy > Math.abs(dx) * verticalLead) {
      state.activate();
    }
  });

const pagerNative = Gesture.Native();
const viewerGesture = Gesture.Simultaneous(dismissPan, pagerNative);

Gesture Handler's native gesture brings the pager's native recognizer into the same gesture-composition graph. Continuous translation stays on the UI runtime. Cross into JavaScript only after a release decision needs async measurement, navigation or player orchestration.

Controls must have an explicit relationship with the gesture region. A scrubber should not begin vertical dismissal while seeking. An empty region around the controls should still belong to the viewer. Test hit regions rather than assuming z-index also defines recognizer priority.

Figure 05

the whole viewport participates in the gesture arena

Two-panel gesture diagram: whole-viewport touches route horizontal intent to the pager and vertical intent to dismissal; release then either cancels to the same viewer without remounting or commits a return handoff.
First classify the touch: horizontal intent belongs to the pager and vertical intent to dismissal. Then classify the release: cancellation restores the same viewer; commitment begins the return handoff.
Read the diagram description

Two large panels separate gesture arbitration from release policy. The whole viewport participates, including letterbox space. A cancelled release does not remount; a committed release returns the media to its feed rectangle.

Cancellation cannot recreate the viewer

Interactive dismissal has two outcomes:

Implementation speciments
type ReleaseDecision =
  | { kind: "cancel" }
  | { kind: "commit"; projectedDistance: number };

Use distance, velocity and projection:

Implementation speciments
export function decideRelease(input: {
  dragY: number;
  velocityY: number;
  dismissDistance: number;
}): ReleaseDecision {
  const progress = input.dragY / input.dismissDistance;
  const projected = input.dragY + Math.max(0, input.velocityY) * 0.18;

  const commit =
    progress >= 0.32 ||
    (progress >= 0.1 &&
      (input.velocityY >= 600 ||
        projected >= input.dismissDistance * 0.48));

  return commit
    ? { kind: "commit", projectedDistance: projected }
    : { kind: "cancel" };
}

These are starting values, not universal constants.

On cancellation, animate the same Reanimated shared values back to their settled values. Do not dismiss and reopen the overlay. Do not reacquire the player. Do not change page identity.

Implementation speciments
function cancelDismissal() {
  phase.value = "cancelling";

  dragY.value = withSpring(0, cancelSpring, (finished) => {
    if (!finished) return;
    phase.value = "settled";
  });

  dragScale.value = withSpring(1, cancelSpring);
  scrimOpacity.value = withTiming(1, { duration: 120 });
  cornerRadius.value = withSpring(0, cancelSpring);
}

This is why cancellation deserves a named state. Treating it as “the gesture ended but close returned false” makes it easy for effects tied to isClosing to dismantle the viewer before the recovery spring completes.

Film 03

cancellation is a real state, not a failed close

Several downward drags recover to the same settled viewer before a later drag commits. Nothing is dismissed and recreated during cancellation.
Read the film description

A portrait image opens into the viewer. Multiple short downward drags return it to fullscreen without changing media identity. A longer release then commits dismissal to the feed.

Closing needs a current target

When dismissal commits, ask the feed to reveal and measure the current media key:

Implementation speciments
async function resolveReturnTarget(
  session: ContinuitySession,
): Promise<MeasuredFrame | null> {
  await registry.reveal(session.key);
  await nextLayoutFrame();

  const measured = await registry.measure(session.key);
  if (!measured) return null;
  if (!sameMedia(measured.key, session.key)) return null;

  return measured;
}

For a multi-item feed post, reveal may first update the inline gallery to the viewer page. If the row is offscreen, it may scroll without animation, wait for layout, then retry measurement. Every successful measurement increments a version.

A practical close sequence:

  1. Freeze the release geometry.
  2. Ask the feed to reveal the active key.
  3. Use a matching cached rectangle only if it is recent and versioned.
  4. Start return immediately when the cached target is credible.
  5. Accept one fresh retarget early in the return animation.
  6. Lock geometry after the retarget cutoff.
  7. If no target exists, fade/direct-dismiss rather than animating to fiction.
  8. Hand the player back or pause it according to captured intent.

Figure 06

the opening rectangle cannot be trusted on return

A media viewer rejects its stale opening rectangle and resolves a fresh return target through reveal, paint and measure.
While the viewer is open, the feed can move or recycle its row. Closing must reveal the active media, allow layout to paint, then measure a fresh destination.
Read the diagram description

The original opening rectangle becomes stale after the feed moves. A reveal, paint and measure sequence resolves the current media rectangle before the closing handoff begins.

Late retargeting is worse than a fallback. If the destination jumps near the end, the system converts measurement accuracy into visible instability.

Playback handback is separate from presentation handback

Suppose the inline video was playing when the user opened it. The viewer uses the same player and continues playback. When the surface returns, the inline host should resume presenting the same session.

Suppose it was paused. Opening should not invent playback merely because the viewer mounted.

Capture intent:

Implementation speciments
function capturePlayback(player: VideoPlayer): PlaybackSnapshot {
  return {
    currentTime: player.currentTime,
    playing: player.playing,
    muted: player.muted,
    capturedAt: Date.now(),
  };
}

If playback intentionally continues while transport work runs, projected time can account for elapsed wall time:

Implementation speciments
function projectedTime(snapshot: PlaybackSnapshot) {
  if (!snapshot.playing) return snapshot.currentTime;
  return snapshot.currentTime + (Date.now() - snapshot.capturedAt) / 1000;
}

Apply intent only after confirming the active session and player identity. Presentation can return before audio or playback is allowed to continue, so model audible ownership explicitly if the application ever permits more than one prepared player.

App lifecycle and native failure are part of the transition

A viewer can background while opening, while playing, during a cancelled drag or while waiting for a return target.

The continuity coordinator needs policies for:

  • background: pause and record a lifecycle pause reason;
  • foreground: resume only if the pause was lifecycle-owned and the session is still active;
  • player invalidation: clear listeners, release and fall back to poster;
  • missing source on close: pause or hand back deterministically, then direct-dismiss;
  • unresolved opening: watchdog to restore the inline owner;
  • unresolved return spring: visual-rest finalizer plus watchdog;
  • rotation: invalidate measurement versions and rebuild the stage;
  • reduced motion: crossfade or immediate ownership transfer without spatial movement.

Watchdogs are recovery paths. They should emit evidence and leave the system in a named state rather than silently pretending the intended animation finished.

Prove the interval, not the settled screen

A screenshot of the open viewer proves the final page. readyToPlay proves player readiness. Playback progress proves the player clock moved. None of them proves the handoff.

Instrument application-owned events:

Implementation speciments
type ContinuityEvent =
  | { type: "source.measured"; token: number; key: MediaKey; version: number }
  | { type: "owner.changed"; token: number; owner: PresentationOwner }
  | { type: "surface.mounted"; token: number; surfaceId: string }
  | { type: "surface.firstFrame"; token: number; surfaceId: string }
  | { type: "dismiss.release"; token: number; decision: "cancel" | "commit" }
  | { type: "return.measured"; token: number; version: number }
  | { type: "handoff.completed"; token: number; owner: "source" | "viewer" }
  | { type: "fallback"; token: number; reason: string };

Then capture the visual interval:

  • timestamped screen recording;
  • high-frequency frames around ownership changes;
  • contact sheets for open, first frame, cancellation and return;
  • source/target rectangle overlays in a debug build;
  • identity, phase and owner logs from the same run;
  • repeated cycles, not one clean take;
  • portrait and landscape video;
  • warm and cold player states;
  • offscreen or recycled return targets;
  • background/resume during a session;
  • deliberately slow network and missing poster.

A useful acceptance rule is:

During every validated handoff interval, the expected media must have at least one visible presentation owner and no more than one unmasked presentation owner.

Automated frame comparison can detect black intervals and large duplicate regions, but retain human review for crop continuity, subtle surface swaps and gesture recovery.

Read failures backwards from the pixels

When this system fails, begin with the first dishonest frame rather than the last callback in the console. Name what the user actually saw, then ask which piece of truth would have had to be wrong for that frame to exist.

What appears on screenUsually meansInspect first
Two copies during openingSource and transport both presentedTransport-mounted → source-hidden ordering
A black video intervalNo surface or poster owned visible pixelsCurrent Teleport host, poster gate, first-frame event
Poster over a playing videoReadiness belonged to another surface/sessionToken, media key and expected surface ID
Video restarts in fullscreenPlayer lifetime followed component lifetimeEvery player create/release call site
First close frame jumpsClose forgot the live drag geometryShared drag values when transport mounted
Close flies to an old row positionOpening geometry was reusedReveal → paint → current-measure trace
Close targets the wrong itemIdentity was URI/index or a recycled handle survivedFull media key and registry cleanup
Short drag dismisses and reopensCancellation ran the close pathPhase trace, pager mount ID, player session ID
Letterbox ignores the dragGesture detector covers only media boundsFull-viewport gesture arena
Android video sits above the scrimThe surface is outside ordinary view compositionsurfaceType and transition surface model

For each failure, find the event that first granted the wrong owner. Check its token, full key, expected surface and measurement version. Fix that contract before touching duration or easing. Curve changes can move the dishonest frame somewhere harder to notice; they cannot make it truthful.

Build the broken versions deliberately

The architecture becomes much less abstract when each invariant is made to fail deliberately. A development build can expose these toggles:

Broken modeDeliberate mistakeVisible result
Dual ownerKeep source and transport visibleDuplicate/brightened pixels during motion
Readiness shortcutHide poster on readyToPlayBlack or unpresented destination interval
Progress shortcutTreat currentTime > 0 as visual proofPoster/live mismatch on the wrong surface
Historical returnReuse opening rectangleReturn misses after feed scroll or recycling
Surface mismatchAnimate an unsuitable Android surfaceZ-order, clipping or transform artefacts
Gesture-on-bitmapWrap only media boundsLetterbox becomes a dead dismissal region
Remount on cancelClose/reopen after failed thresholdPlayback, crop or chrome resets
URL identityKey sessions by URIDelayed callbacks bleed between duplicate assets

Each failure isolates a boundary that can otherwise remain hidden behind a plausible final frame.

Implementation order

Build in this order:

  1. Stable MediaKey.
  2. Measurement registry.
  3. Fullscreen image viewer with no transition.
  4. Ownership coordinator and token checks.
  5. Image transition surface.
  6. Full-viewport vertical dismissal.
  7. Cancellation with no viewer remount.
  8. Fresh return-target resolution.
  9. Horizontal paging and active-page identity.
  10. App-owned video session registry.
  11. Poster and first-visible-frame contract.
  12. Android surface adapter.
  13. iOS player-layer adapter if live-surface movement is required.
  14. Backgrounding, missing-target and watchdog recovery.
  15. Evidence events and repeated visual scenarios.

Do not begin with native video movement. Prove the ownership system using images, then add video as a specialised player/surface implementation behind the same phases.

What I would tighten next

The interaction is reliable, but the model still carries debts worth removing:

  • measurement versions should include a layout epoch that changes on rotation, inset and viewport changes;
  • audible ownership should be explicit rather than derived from player and muted state;
  • every viewer animation should respect reduced-motion policy;
  • handler registries should use ownership tokens rather than one mutable module-level slot;
  • frame-diff evidence should become a validity gate instead of an optional artifact;
  • native patches should become a narrow maintained module or upstream change rather than full-file replacements where possible.

The next revision should make those boundaries explicit in code: layout epochs, audible ownership, reduced-motion policy, tokenised handlers, frame-diff validity gates and a maintained native surface module.

The complete mental model

The feed owns the media entity. The active player session owns playback. The source, transport and viewer take turns owning presentation. The gesture owns interactive geometry only while it is active. The measurement registry owns the current answer to “where can this media return?” Native adapters own the platform surface mechanism. The coordinator owns the order in which those rights move.

Once those boundaries exist, the animation values become tractable. Without them, a polished spring can conceal competing surfaces in a recording while the interaction still feels wrong in the hand.

That was the missing machinery behind “ownership, not curves.”

Reference material

Implementation materials

Executable source, ownership contracts and validation material.
Runnable reference · Expomedia-continuity-reference-app.zipA standalone Expo app with a recycled feed, one teleported VideoView, ownership state machine, native pager, dismissal cancellation, current-target return and contract tests.Expo SDK 56 · source + testsDownload ↓Build contract · MarkdownAGENT_IMPLEMENTATION_CONTRACT.mdThe exact invariants, implementation order, platform constraints, failure policy and acceptance evidence to hand to a developer or coding agent.Markdown · agent-readyDownload ↓Artifact · TypeScriptmedia-continuity-reference.tsIdentity, phases, ownership, frame gating, release policy and return-target contracts in one annotated specimen.TypeScript · clean roomDownload ↓Checklist · Markdownmedia-continuity-test-matrix.mdA device and failure-mode matrix for proving the transition interval rather than only the settled screen.Markdown · reusableDownload ↓Companion essayownership, not curvesThe shorter argument for treating continuity as an ownership problem.10 min readOpen ↗

Primary references

Implementation and transport:

Animation and interaction:

Native video surfaces: