react-native-shared-hero
v1.0.1
Published
High-performance native shared element (hero) transitions for React Native — Fabric (New Architecture), router-agnostic, iOS & Android.
Maintainers
Readme
react-native-shared-hero
High-performance, fully-native shared-element ("hero") transitions for React Native. Every flight runs in Swift and Kotlin on the New Architecture (Fabric) — no JS-thread animation — and the library is router-agnostic: it matches a source and destination by id across mount/unmount and flies a snapshot in a window-level overlay, with no dependency on any navigation library. Works in bare React Native and Expo apps (via a development build).
How it works under the hood — see ARCHITECTURE.md.
Showcase
| Android | iOS | | --- | --- | | | |
Table of contents
- Features
- Why this library?
- Requirements
- Installation
- Quick start
- API reference
- Use cases
- Example app
- Under the hood
- Contributing
- License
Features
- Fully native flight engine in Swift and Kotlin on the New Architecture (Fabric) — no JS-thread animation and no Paper/legacy fallback.
- Router-agnostic by design: matches elements by
id(keyednamespace::id), so it never imports or depends on a navigation library. - Works everywhere a screen can change: native-stack push/pop, native modals, transparent modals, form sheets, in-screen tabs, virtualized
FlatLists, multi-step navigation chains, and plain in-placeuseStatetoggles. - Window-level overlay so the flying element renders above modals, transparent modals, sheets, and even React Native's core
<Modal>(a separate window). - Two transition styles:
snapshot(clone, translate + scale + crossfade) andmorph(Material container transform that also interpolates corner radius and background color). - Rounded-corner and frame morphing — bounds, corner radius, and background color animate together in
morphmode. - Spring or duration timing: a physical spring config or a time-based curve with easing presets (
standard,emphasized, and the usual ease in/out). - Linear or arc motion paths for the flying element's centre, plus configurable
fadeMode(cross,in,out,through). - Interactive gesture returns on iOS: the left-edge swipe-back pop and the sheet swipe-down dismiss are tracked frame-by-frame and synced through the host navigator's transition coordinator.
- Per-element opt-outs:
enableddisables participation without unmounting, andreturnFlightEnabledsuppresses a redundant back-flight when the dismissal already carries the element away. - In-place transitions via the JS-only
useSharedHerohelper — toggle two subtrees with the sameidand the unmount→mount match flies automatically. - Transition callbacks:
onTransitionStart/onTransitionEndfire on the source and destination views.
Why this library?
The established options — react-native-shared-element (low-level native "primitives") and its react-navigation-shared-element binding — pioneered native shared-element transitions in React Native and are still great references. But both are now explicitly looking for a new maintainer, predate the New Architecture, and the navigation binding only ever supported the JS Stack (its Native Stack support was never finished). react-native-shared-hero is built for where React Native is today.
| | react-native-shared-hero | react-native-shared-element | react-navigation-shared-element |
| --- | --- | --- | --- |
| New Architecture (Fabric) | ✅ Built for it (Swift + Kotlin) | ❌ Predates it | ❌ Predates it |
| Maintenance | ✅ Actively developed | ⚠️ Seeking a maintainer | ⚠️ Seeking a maintainer |
| Setup | ✅ Declarative — drop <SharedHero id namespace> on both screens | ⚙️ Manual: capture nodes, render the transition overlay, drive a position value yourself | ✅ Declarative, but only via React Navigation |
| Navigator dependency | ✅ None (router-agnostic) | ✅ None (but you build the transition engine) | ❌ React Navigation only |
| Native Stack (react-native-screens) | ✅ First-class | n/a (primitive) | ❌ JS Stack only (Native Stack unfinished) |
| Beyond stack: modals / sheets / tabs / FlatList / in-place | ✅ All of these, plus the core <Modal> | ⚙️ Whatever your engine implements | ❌ JS Stack screens only |
| Interactive gesture return | ✅ iOS edge-swipe + sheet swipe-dismiss, synced to the transition coordinator | ➖ Driven by an external position value | ➖ Whatever the navigator provides |
| Fine-grained image resize / text-clip modes | ➖ Coarser (snapshot / morph, fadeMode) | ✅ Rich resize (auto/stretch/clip/none) + align matrix | ✅ Inherits the primitive's modes |
| Maturity / install base | 🆕 New | ✅ Battle-tested, large adoption | ✅ Battle-tested, large adoption |
When the alternatives are still a good fit: if you need the very granular image resizeMode transitions or text clip-reveal alignment that react-native-shared-element exposes, or you want a low-level position-driven primitive to wire into a custom (non-navigation) transition engine, those libraries remain excellent for that.
Choose react-native-shared-hero when you want a modern, New-Architecture-native, fully declarative shared-element library that works across your whole app — any navigator (or none), native stacks, modals, sheets, tabs, lists, and in-place toggles — with interactive gesture-driven returns out of the box.
Requirements
- React Native New Architecture (Fabric) enabled — the component ships as a Fabric
codegenNativeComponentand has no Paper/legacy fallback. - iOS: Swift 5, C++20 (configured by the podspec).
- Android:
minSdkVersion24+.
The only peer dependencies are react and react-native. The library does not require react-navigation or react-native-screens — they are used only by the example app.
Installation
npm install react-native-shared-heroor
yarn add react-native-shared-heroThen install pods for iOS:
cd ios && pod installMake sure the New Architecture is enabled in your app (it is the default on recent React Native versions).
Use with Expo
This library contains custom native code, so it does not run in Expo Go. Use an Expo development build instead — there's no config plugin to add, the module is autolinked during prebuild.
npx expo install react-native-shared-heroThen build and run a development build (these run prebuild and compile the native project):
npx expo run:ios
# or
npx expo run:androidOr build it with EAS:
eas build --profile development --platform ios # or androidRequires the New Architecture, which is enabled by default on Expo SDK 52 and later. On older SDKs, enable it in app.json / app.config.js:
{
"expo": {
"newArchEnabled": true
}
}Quick start
Render a SharedHero with the same id (and namespace) on the two screens you want to connect. When one unmounts and the other mounts within roughly one native frame, the library captures the source and flies it into the destination automatically — no imperative calls, no navigation hooks.
import { SharedHero } from 'react-native-shared-hero';
import { Image } from 'react-native';
// List screen
function ListItem({ photo, onPress }) {
return (
<Pressable onPress={onPress}>
<SharedHero id={`photo-${photo.id}`} namespace="gallery" style={styles.thumb}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>
</Pressable>
);
}
// Detail screen
function Detail({ photo }) {
return (
<SharedHero id={`photo-${photo.id}`} namespace="gallery" style={styles.hero}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>
);
}That is the whole API for navigation-driven transitions. For same-screen toggles you can optionally use the useSharedHero helper.
API reference
SharedHero (also exported as SharedHeroView) accepts all standard ViewProps (including style and children) plus the following:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| id | string | — (required) | Stable identifier matched across screens. A flight runs when a hero with this id unmounts and another with the same id mounts within ~1 native frame. |
| namespace | string | 'default' | Optional namespace; lets you run multiple isolated registries. Matching key is namespace::id. |
| mode | 'snapshot' \| 'morph' \| 'shuttle' \| 'zoom' \| 'auto' | 'snapshot' | Transition style. snapshot: clone, translate + scale + crossfade. morph: Material container transform (also interpolates corner radius + background color). shuttle: aliases snapshot in v1 (reserved for a v2 custom-subtree portal). zoom/auto: reserved for the iOS 18+ system zoom transition; currently alias morph. |
| duration | number | 320 | Animation duration in ms. Ignored when spring is set. |
| spring | { damping?: number; stiffness?: number; mass?: number } | — | Spring config; overrides duration. A spring is used only when both stiffness and mass are non-zero. |
| fadeMode | 'cross' \| 'in' \| 'out' \| 'through' | 'cross' | How source/destination content fade during the flight. |
| easing | 'linear' \| 'easeIn' \| 'easeOut' \| 'easeInOut' \| 'standard' \| 'emphasized' | 'standard' | Easing preset for time-based flights. |
| motionPath | 'linear' \| 'arc' | 'linear' | Path of the flying element's centre. linear: straight line. arc: Material-style curved arc. |
| enabled | boolean | true | Disable participation in flights without unmounting. |
| returnFlightEnabled | boolean | true | Whether this hero produces a return (back) flight when it unmounts. Set false for a hero whose dismissal already carries the element away (e.g. a core <Modal> that slides down on dismiss), to avoid a redundant return flight. Only the unregister/back-flight path honours this; the inbound flight is unaffected. |
| onTransitionStart | (e: { id: string; namespace: string }) => void | — | Fires on the source view when its outbound flight starts. |
| onTransitionEnd | (e: { id: string; namespace: string }) => void | — | Fires on the destination view when its inbound flight ends. |
useSharedHero
A small imperative helper for same-screen ("in-place") transitions. It does not talk to native — it just toggles React state so you can conditionally render two SharedHero subtrees with the same id; the library auto-detects the unmount→mount match within one frame.
import { useSharedHero } from 'react-native-shared-hero';
const { active, toggle } = useSharedHero();
return (
<Pressable onPress={toggle}>
{active ? <ExpandedCard /> : <CollapsedCard />}
</Pressable>
);Returns { active, start, end, toggle }. For navigation-driven flights you do not need this hook at all.
Use cases
The sections below mirror the example app's screens (example/src/screens/**). Together they show how far an id-matched, router-agnostic model goes — from the simplest list→detail image to interactive gesture returns and cross-window modals. Run the example app to see them all live.
Each use case has a placeholder table for your Android and iOS recordings.
Basic image hero
The simplest case — a list thumbnail grows into the detail header using the default snapshot mode.
| Android | iOS | | --- | --- | | | |
// List
<SharedHero id={`basic-${photo.id}`} namespace="basic" mode="snapshot" duration={360} style={styles.thumbWrap}>
<Image source={{ uri: photo.uri }} style={styles.thumb} />
</SharedHero>
// Detail — same id + namespace
<SharedHero id={`basic-${photo.id}`} namespace="basic" mode="snapshot" duration={360} style={styles.heroWrap}>
<Image source={{ uri: photo.uri }} style={styles.hero} />
</SharedHero>FlatList (virtualized)
A shared hero originating in a virtualized FlatList of ~60 items — the source row may be recycled or unmounted while you scroll, yet the flight still resolves because matching is by id, not by view instance.
| Android | iOS | | --- | --- | | | |
const renderItem = ({ item }) => (
<TouchableOpacity onPress={() => navigation.navigate('FlatListHeroDetail', { id: item.id })}>
<SharedHero id={`flatlist-${item.id}`} namespace="flatlist" mode="snapshot" duration={360} style={styles.thumbWrap}>
<Image source={{ uri: flatUri(item.id) }} style={styles.thumb} />
</SharedHero>
</TouchableOpacity>
);Card morph (Material container)
mode="morph" interpolates corner radius, background color and bounds together — the Material container transform.
| Android | iOS | | --- | --- | | | |
<SharedHero
id={`card-${photo.id}`}
namespace="card"
mode="morph"
duration={420}
style={[styles.card, { backgroundColor: photo.color }]}
>
<View style={styles.cardInner}>{/* image + text */}</View>
</SharedHero>Native modal hero
Push a presentation: 'modal' native-stack screen with a shared element. The hero traverses the modal boundary because the overlay renders at the window level.
| Android | iOS | | --- | --- | | | |
<SharedHero id={`modal-${photo.id}`} namespace="modal" mode="snapshot" duration={380} style={styles.thumb}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>Transparent modal hero
A presentation: 'transparentModal' screen — the case where the flying element would otherwise be obstructed. Window-level overlay rendering keeps the snapshot on top.
| Android | iOS | | --- | --- | | | |
<SharedHero
id={`tmodal-${photo.id}`}
namespace="tmodal"
mode="morph"
duration={420}
style={[styles.hero, { backgroundColor: photo.color }]}
>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>Tabs → detail hero
A card inside a custom in-screen tab pane pushes to a stack detail and the element still flies — the registry only cares about id matching, not which navigator (or tab) hosted the trigger.
| Android | iOS | | --- | --- | | | |
<SharedHero
id={`tabs-${photo.id}`}
namespace="tabs"
mode="morph"
duration={400}
style={[styles.card, { backgroundColor: photo.color }]}
>
<View style={styles.cardInner}>{/* thumb + text */}</View>
</SharedHero>FormSheet hero
A presentation: 'formSheet' screen — a true UIKit sheet on iOS, the native-stack sheet style on Android. The hero flies into the sheet body.
| Android | iOS | | --- | --- | | | |
<SharedHero id={`sheet-${photo.id}`} namespace="sheet" mode="snapshot" duration={380} style={styles.hero}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>In-place toggle
No navigation at all — a useState toggle swaps a small SharedHero for a large one with the same id. A distinct React key forces an unmount→mount of the same id within one commit, which is exactly the router-agnostic in-place match path.
| Android | iOS | | --- | --- | | | |
{expanded ? (
<SharedHero key="hero-inplace-large" id="hero-inplace" namespace="inplace" mode="snapshot" duration={420} style={styles.large}>
<Image source={{ uri: PHOTO.uri }} style={styles.fill} />
</SharedHero>
) : (
<SharedHero key="hero-inplace-small" id="hero-inplace" namespace="inplace" mode="snapshot" duration={420} style={styles.small}>
<Image source={{ uri: PHOTO.uri }} style={styles.fill} />
</SharedHero>
)}Spring vs duration
The same hero with the two timing models side by side: a fixed duration with an easing curve, vs a physical spring.
| Android | iOS | | --- | --- | | | |
// Duration timing
<SharedHero id="svd-duration" namespace="svd-duration" mode="morph" duration={360} style={styles.thumb}>
<Image source={{ uri: PHOTO.uri }} style={styles.fill} />
</SharedHero>
// Spring timing
<SharedHero id="svd-spring" namespace="svd-spring" mode="morph" spring={{ damping: 16, stiffness: 200, mass: 1 }} style={styles.thumb}>
<Image source={{ uri: PHOTO.uri }} style={styles.fill} />
</SharedHero>Arc path motion
motionPath="arc" traces a quadratic curve between the source and destination centres, paired here with the emphasized easing.
| Android | iOS | | --- | --- | | | |
<SharedHero
id={`arc-${photo.id}`}
namespace="arc"
mode="morph"
motionPath="arc"
duration={520}
easing="emphasized"
style={[styles.thumb, { backgroundColor: photo.color }]}
>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>Custom shuttle
fadeMode="through" fades the source fully out before the destination's (totally different) layout fades in — Flutter's flightShuttleBuilder feel without the JSX gymnastics.
| Android | iOS | | --- | --- | | | |
<SharedHero
id={`shuttle-${photo.id}`}
namespace="shuttle"
mode="morph"
fadeMode="through"
duration={520}
easing="emphasized"
style={[styles.card, { backgroundColor: photo.color }]}
>
{/* source: small thumb + label; destination: full-bleed hero */}
</SharedHero>Drag-to-dismiss (gesture return)
A gesture-driven interactive return. On iOS the left-edge swipe-back is tracked frame-by-frame and synced to the navigator's transition coordinator (see ARCHITECTURE.md). The example also demonstrates a JS-driven drag whose release slingshots the hero back to its origin cell.
| Android | iOS | | --- | --- | | | |
// Keeping the hero mounted inside the dragged wrapper means the back-flight
// captures a live source at the dragged position.
<Animated.View {...panResponder.panHandlers} style={[styles.heroOuter, { transform: [{ translateY }, { scale }] }]}>
<SharedHero id={`gesture-${photo.id}`} namespace="gesture" mode="snapshot" duration={360} style={styles.heroWrap}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>
</Animated.View>Multi-step navigation
Each detail screen shows an "Up next" thumbnail; tapping it pushes a deeper detail whose big hero shares the tapped thumbnail's id, so a flight runs at every step of the chain.
| Android | iOS | | --- | --- | | | |
// Big hero for the current photo
<SharedHero id={`multi-${id}`} namespace="multi" mode="snapshot" duration={360} style={styles.heroWrap}>
<Image source={{ uri: photo.uri }} style={styles.hero} />
</SharedHero>
// "Up next" thumbnail — its id matches the next step's big hero
<SharedHero id={`multi-${nextPhoto.id}`} namespace="multi" mode="snapshot" duration={360} style={styles.nextThumbWrap}>
<Image source={{ uri: nextPhoto.uri }} style={styles.nextThumb} />
</SharedHero>Core Modal (React Native)
A hero into React Native's core <Modal> — on iOS a separate UIWindow (RCTModalHostView) outside the navigator, on Android a separate Dialog window. The overlay is layered above that window so the flight stays visible; returnFlightEnabled={false} is not used here, but the dismiss is a plain slide so the same id matches back to the list thumbnail.
| Android | iOS | | --- | --- | | | |
// Trigger in the list
<SharedHero id={`core-modal-${photo.id}`} namespace="core-modal" mode="snapshot" duration={380} style={styles.thumb}>
<Image source={{ uri: photo.uri }} style={styles.fill} />
</SharedHero>
// Destination inside RN's <Modal>
<Modal visible transparent animationType="slide" onRequestClose={close}>
<SharedHero id={`core-modal-${active.id}`} namespace="core-modal" mode="snapshot" duration={380} style={styles.hero}>
<Image source={{ uri: active.uri }} style={styles.fill} />
</SharedHero>
</Modal>Example app
The example/ workspace contains every use case above, wired through @react-navigation/native-stack + react-native-screens (used purely to demonstrate router-agnosticism — the library does not depend on them).
yarn # install from the repo root (uses Yarn workspaces)
yarn example start
# in another terminal
yarn example ios
# or
yarn example androidUnder the hood
The interesting parts are native (Swift/Kotlin). Two docs go deep:
- ARCHITECTURE.md — how the registry, snapshots, flights, overlay, and the interactive controllers work, plus the react-native-screens / navigation interop.
- LESSONS_LEARNED.md — the hard-won bugs, cross-window gotchas, and design decisions behind the current shape (and the rules that keep them from coming back).
Contributing
License
MIT © Duc Trung Mai
Made with create-react-native-library.
