A phone showing a card morphing into a fullscreen view, with a gray-blue cat watching from the background

React Native Morph Card: The App Store Expand Trick, Done Natively

The Gray Cat
The Gray Cat
0 views

If you have ever tapped an app in the iOS App Store and watched the little card swell up into a fullscreen detail page, then collapse neatly back when you swipe away, you already know the effect React Native Morph Card is chasing. It is one of those interactions that feels expensive to build and instantly recognizable when done right. The card grows, its corners straighten, its position slides into place, and the whole thing feels like one continuous object rather than two separate screens.

react-native-morph-card is a small, sharply focused library that does exactly this one thing. It is not a general-purpose animation toolkit and it does not try to be a shared-element framework. It wraps a card on your list screen, wraps a landing spot on your detail screen, and runs the morph between them using native platform animations rather than JavaScript-driven ones. On iOS it leans on UIKit's UIViewPropertyAnimator; on Android it uses ValueAnimator. There is no webview, no experimental flag to toggle, and no Reanimated dependency to manage.

It is worth being upfront: this is a young library. At version 0.4.0 it is barely a few months old, maintained by a single author (Melissa Valesca, associated with the React Native consultancy App & Flow), and adoption is still tiny. Treat it as new and interesting rather than battle-hardened. But the API is clean, the technical approach is genuinely clever, and the effect is hard to get this smoothly any other way.

What Makes It Tick

A few things set this library apart from the pure-JavaScript card-modal alternatives floating around the ecosystem.

  • Native animations on both platforms. The motion runs on the platform's own animation engine, so it stays smooth even when the JavaScript thread is busy. This is the core differentiator versus JS-only morph libraries.
  • No webview, no experimental flags. It ships as a real native module with nothing fragile to opt into.
  • Navigator-agnostic. It works with React Navigation, expo-router, or anything that exposes a goBack() method.
  • New Architecture native. The package ships codegen specs for Fabric native components, which means it is designed for React Native's New Architecture from the ground up.
  • Live children after the morph. During the animation the card is a bitmap snapshot, but once the expansion finishes the source's children are cloned into the target as real, live React components, so timers, animated values, and live data keep ticking.

That last point is the one I find most satisfying, and we will come back to it.

Getting It Into Your Project

Installation is the usual native-module dance. Add the package, then install pods on iOS.

npm install react-native-morph-card

Or with Yarn:

yarn add react-native-morph-card

Then link the native side on iOS:

cd ios && pod install

Android needs no extra steps. The one hard requirement to keep in mind: your app must have the New Architecture (Fabric) enabled, since this ships as a Fabric native component. The development matrix the library targets is Node 18+, React Native 0.80, React 19, Xcode 15+, and JDK 17. If you are still on the old architecture, this library will not work for you yet, and that is the single biggest gating factor before you adopt it.

The Mental Model: Source, Target, Dismiss

Three pieces do all the work, and once they click the rest is easy.

<MorphCardSource> wraps the card on your list or grid screen. When tapped, it captures a snapshot of its content and hands you a native view tag through onPress. You pass that tag along when you navigate.

<MorphCardTarget> lives on the detail screen, marking where the card should land. The moment it mounts, it triggers the native expand animation from the source's position and size to its own.

useMorphTarget is a hook that gives you a dismiss() function. Calling it runs the collapse animation back to the original card and then calls your navigator's goBack().

Wiring The Card On The List Screen

The source wrapper describes the card's resting shape and forwards the native tag when tapped. Everything inside it is your normal React Native content.

import React from 'react';
import { Image } from 'react-native';
import { MorphCardSource } from 'react-native-morph-card';
import type { NavigationProp } from '@react-navigation/native';

const albumArt = require('./assets/album.jpg');

function ListScreen({ navigation }: { navigation: NavigationProp<any> }) {
  return (
    <MorphCardSource
      width={200}
      height={150}
      borderRadius={16}
      expandDuration={350}
      onPress={(sourceTag) => navigation.navigate('Detail', { sourceTag })}
    >
      <Image source={albumArt} style={{ width: 200, height: 150 }} />
    </MorphCardSource>
  );
}

The sourceTag you receive is a native view tag, not a React key. It is the handle the native side uses to find the original card later, both to animate from it and to collapse back to it. You simply pass it through your navigation params.

Landing The Morph On The Detail Screen

On the detail screen you drop the target where the card should grow to, and wire up the dismiss hook. The target's width, height, and borderRadius describe the card's final, expanded shape.

import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { MorphCardTarget, useMorphTarget } from 'react-native-morph-card';

function DetailScreen({ route, navigation }: any) {
  const { sourceTag } = route.params;
  const { dismiss } = useMorphTarget({ sourceTag, navigation });

  return (
    <View style={{ flex: 1 }}>
      <MorphCardTarget
        sourceTag={sourceTag}
        width="100%"
        height={300}
        borderRadius={0}
        collapseDuration={200}
      />
      <Pressable onPress={dismiss}>
        <Text>Close</Text>
      </Pressable>
    </View>
  );
}

Setting borderRadius={0} on the target means the rounded card squares off as it fills the width, which mirrors the App Store feel exactly. Tapping Close runs the collapse and pops the screen in one motion.

The Navigation Setup That Actually Matters

This is the part that trips people up, so it is worth stating plainly. The detail screen must be presented as a modal (a transparentModal or fullScreenModal in React Navigation), and the navigator's built-in screen transition must be disabled with animation: 'none'.

<Stack.Screen
  name="Detail"
  component={DetailScreen}
  options={{
    presentation: 'transparentModal',
    animation: 'none',
    headerShown: false,
  }}
/>

The reasoning is straightforward once you see it. The source screen needs to stay mounted underneath while the morph plays, so the card it is animating from still exists. And if the navigator runs its own slide-or-fade transition at the same time as the morph, you get two animations fighting each other and a broken, doubled effect. Hand the motion entirely to Morph Card and tell the navigator to sit still.

Going Further

Once the basic flow works, a handful of extra capabilities open up more polished and playful results.

Handling Images With resizeMode

There is a subtle detail in how the morph swaps content. While the animation runs, the card is a bitmap snapshot. Once it finishes, the library normally swaps that snapshot for the source's actual children rendered live. That handoff is great for text, custom views, and anything with live state, but it does not play well with a raw <Image> whose dimensions change during the morph, because native image scaling does not apply to cloned React components the same way.

The fix is resizeMode. Setting it tells the library to keep the bitmap after the expansion rather than swapping in live children, so the image scales correctly throughout.

<MorphCardSource
  width={200}
  height={150}
  borderRadius={16}
  resizeMode="cover"
  onPress={(sourceTag) => navigation.navigate('Detail', { sourceTag })}
>
  <Image source={albumArt} style={{ width: 200, height: 150 }} />
</MorphCardSource>

The accepted values are cover, contain, and stretch, matching the familiar React Native image semantics. Rule of thumb: if your card is essentially an image, reach for resizeMode; if it is a composed view with live content, leave it off and enjoy the live-children handoff.

A Little Showmanship With Rotation

Version 0.4.0 added rotation props that let the card spin or tilt as it expands, which is a fun way to give a flashier card a bit of personality. rotations controls how many full 360-degree spins happen during the expand, and rotationEndAngle sets a final resting tilt in degrees. The collapse animation reverses both back to neutral.

<MorphCardSource
  width={180}
  height={180}
  borderRadius={24}
  rotations={1}
  rotationEndAngle={4}
  expandDuration={450}
  onPress={(sourceTag) => navigation.navigate('Detail', { sourceTag })}
>
  <PlaylistCover />
</MorphCardSource>

Under the hood iOS animates transform.rotation.z via CABasicAnimation while Android uses ValueAnimator rotation, so this stays on the native side like everything else. Use it sparingly; a single spin with a few degrees of final tilt reads as playful, while anything more starts to feel like a slot machine.

Driving The Morph Imperatively

If you would rather orchestrate the transition yourself instead of relying on the source and target components mounting, there is a small imperative API. You can trigger expansion and collapse directly and resolve the native view tag from a ref.

import {
  morphExpand,
  morphCollapse,
  getViewTag,
} from 'react-native-morph-card';

const tag = getViewTag(viewRef);     // native view tag from a ref
await morphExpand(sourceRef, targetRef);  // expand source into target
await morphCollapse(sourceTag);           // collapse back to the source

These return promises, so you can sequence other work after the animation settles. This is handy for custom presentation logic or when you are integrating the morph into a flow that does not map cleanly onto a single mount-triggered target.

Wrapper Mode For Backgrounds That Expand Separately

Setting a backgroundColor on the source switches on what the library calls wrapper mode, where the card's background expands independently of its content. On the target side, contentOffsetY and contentCentered let you control where the snapshotted content sits during that expansion, which is useful when you want a colored panel to balloon outward while a logo or thumbnail stays centered or offset within it. It is a niche but elegant option for cards that are more "tile with a background" than "image with rounded corners."

Where It Fits, And Where It Doesn't

The honest framing is that Morph Card occupies a deliberately narrow slot. The general-purpose options, like Reanimated's shared element transitions or the older react-native-shared-element family, can morph arbitrary elements between any two screens, but they carry broader APIs and, in Reanimated's case, a shared-element feature that has long been flagged as experimental. The pure-JavaScript card-modal libraries hit the same App Store effect but animate on the JS thread, which can stutter under load. Morph Card trades generality for doing one specific interaction with real native animation and a small surface area.

The trade-offs are real and worth weighing. It is New Architecture only, very early in its life with low adoption and a single maintainer, and the modal-plus-animation: 'none' setup adds a bit of integration friction. There is also no drag-to-dismiss gesture yet, which is a noticeable gap versus the native iOS behavior it imitates; the roadmap mentions gesture-driven collapse, additional presentation styles, and spring physics configuration as future work.

If you are already on Fabric and you specifically want that recognizable card-to-fullscreen morph without pulling in a heavier animation framework, React Native Morph Card is a delightfully focused way to get it. Go in with eyes open about its age, lean on resizeMode for image cards, respect the modal navigation requirement, and you will have an interaction that feels like it came straight out of a first-party app.