A lightweight, dependency-free shared element transition library for React Native with Expo Router.
@le-sourcier/shared-element enables smooth, seamless transitions between screens. When the user taps an element (a card, a button, an avatar), it visually transforms into the destination element on the next screen — creating the illusion that the element itself is expanding or moving.
Think iOS interactive transitions or Instagram media transitions, but pure JavaScript and Expo-Go-compatible.
- Zero native dependencies — pure JS, runs in Expo Go
- Expo Router-native — auto-keying based on
href/pathname, no manual tag wiring - Cross-platform — iOS and Android
- Multiple presets —
morph,fade,shape,push - Multi-shared-element-safe — many elements per tag, many tags per screen, no flicker on remount
- Tiny — ~5 KB minified
npm install @le-sourcier/shared-element- React Native 0.71+
- Expo SDK 50+
react-native-reanimated3.0+ (4.x recommended)
npm install react-native-reanimated// app/_layout.tsx
import { Stack } from 'expo-router';
import { SharedElementProvider } from '@le-sourcier/shared-element';
export default function RootLayout() {
return (
<SharedElementProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="details/[id]" options={{ animation: 'none' }} />
</Stack>
</SharedElementProvider>
);
}// components/CardCarousel.tsx
import { SharedElement, useSharedElementNavigation } from '@le-sourcier/shared-element';
export function Card({ card }) {
const navigate = useSharedElementNavigation();
return (
<SharedElement href={`/details/${card.id}`} borderRadius={28}>
<Pressable onPress={() => navigate(`/details/${card.id}`)}>
<CardVisual card={card} />
</Pressable>
</SharedElement>
);
}// app/details/[id].tsx
import { SharedElement, useSharedElementBack } from '@le-sourcier/shared-element';
export default function DetailsScreen() {
const back = useSharedElementBack();
return (
<View>
<SharedElement borderRadius={0}>
<CardVisual card={card} compact />
</SharedElement>
<Pressable onPress={back}><Text>Close</Text></Pressable>
</View>
);
}That's it. The auto-key matches href (source) with pathname (destination); no manual tag is required.
Hosts the registry and the overlay layer. Mount it once near the root of your app, above the navigator.
<SharedElementProvider>
{/* your app */}
</SharedElementProvider>Returns a function that runs a forward shared-element transition and pushes the route.
const navigate = useSharedElementNavigation();
await navigate('/details/1');
await navigate('/details/1', {
preset: 'shape',
config: { duration: 320 },
});Parameters:
href(string|Href) — destination URL.opts?(object)tag?(string) — override the inferred tag.preset?('morph' | 'fade' | 'shape' | 'push') — convenience shortcut forconfig.preset.config?(TransitionConfig) — full configuration object.
Returns a function that runs a reverse transition and pops the stack. No arguments needed in 99 % of cases — the library remembers the most recent tag.
const back = useSharedElementBack();
await back();
await back({ preset: 'shape' });Wraps a participating element. The wrapper does not impose a layout (flex: 1 etc.) — pass style to size it explicitly when its parent is a flex container.
// Source — declares destination via href
<SharedElement href="/details/1" borderRadius={16}>
<Pressable><Card /></Pressable>
</SharedElement>
// Destination — auto-keyed from pathname
<SharedElement borderRadius={0}>
<Card />
</SharedElement>Props:
id?(string) — explicit shared-element key. Use this when neither auto-keying rule applies.href?(string) — destination URL (source side only).borderRadius?(number) — captured into the overlay clone and animated to the destination'sborderRadius.style?(StyleProp<ViewStyle>) — applied to the wrapper view.children(ReactNode).
type Frame = { x: number; y: number; width: number; height: number };
type Snapshot = {
id: string;
frame: Frame;
render: () => ReactNode;
borderRadius?: number;
};
type TransitionPreset = 'morph' | 'fade' | 'shape' | 'push';
type TransitionConfig = {
preset?: TransitionPreset;
duration?: number; // ms (used by `shape`, ignored by spring presets)
damping?: number; // spring damping
stiffness?: number; // spring stiffness
mass?: number; // spring mass
crossfadeDuration?: number; // crossfade ms (default tuned per preset)
};| Preset | Best for | Driver |
|---|---|---|
morph |
Card / hero image transitions | Spring on x/y/w/h + timing crossfade |
fade |
Same-position elements (in-place swap) | Pure crossfade |
shape |
Button → FAB, rectangle ↔ circle | Linear interpolation, derives r = min(w,h)/2 per frame so the silhouette stays a perfect stadium throughout |
push |
Carousel item ↔ list row in same column | Spring biased toward translation over scale |
- Capture — the source's frame is measured via
measureInWindow. - Show — an overlay clone is mounted at the captured position; the source is hidden via the provider's hidden-tag store.
- Navigate —
router.pushtriggers the route change. - Wait — the library polls (up to 800 ms) for a registration with the same tag whose pathname differs from the source's.
- Animate — the overlay morphs (spring or timing per preset) toward the destination frame, while the inner content crossfades.
- Hide — the overlay unmounts; the destination becomes visible.
- Pop — the navigation stack returns the active tag.
- Capture / Show — the destination's current frame is measured and overlaid.
- Back —
router.backtriggers the pop. - Wait + Animate + Hide — same as forward, in reverse.
Tags are inferred from props:
- Source (has
href):tag = href. - Destination (no
href, noid):tag = current pathname, frozen at mount. - Auto (
idonly): the tag is exactlyid; the role is resolved by pathname comparison.
Pathnames are frozen at mount because expo-router keeps screens in the stack mounted across URL changes — without freezing, every SharedElement on every mounted screen would report the live URL, breaking destination resolution.
Many SharedElement instances can coexist on the same screen and even share the same tag (e.g. a header on screen A and the matching hero on screen B both auto-key to /details/1).
The provider keeps:
- a
Map<tag, Set<Registration>>registry — every mounted element registers, all are reachable; - a
Set<tag>of currently-hidden tags driven by a pub/sub model.
Each SharedElement subscribes via useSyncExternalStore. When a transition hides or unhides a tag, every subscribed component re-reads the current value synchronously — including any component that mounts mid-transition. This eliminates the "element disappears" race that plagues naive setState-based registries when a screen is unmounted and remounted across a navigation push/pop.
A single screen may host any number of independently-keyed SharedElements. The header (auto-keyed to its pathname) and a "Continue" button (href to a different route) coexist without interference because their tags are distinct. Tags are per-element, not per-screen.
When URLs don't match but you want to share elements:
// Source — URL is "/details/1"
<SharedElement href="/details/1" id="apple"><Image /></SharedElement>
// Destination — URL is "/details/fruit-1"
<SharedElement id="apple"><Image /></SharedElement>await navigate('/details/1', {
config: { damping: 30, stiffness: 200, mass: 0.8 },
});<SharedElement href="/details/card-2" borderRadius={16} style={{ height: 56 }}>
<Pressable onPress={() => navigate('/details/card-2', { preset: 'shape' })}>
<Text>Continue</Text>
</Pressable>
</SharedElement>
// Destination
<SharedElement borderRadius={999} style={{ width: 80, height: 80 }}>
<Pressable style={styles.circle}><Text>▶</Text></Pressable>
</SharedElement>The shape preset interpolates linearly so the silhouette stays a perfect stadium (radius = min(w,h)/2) at every frame — no wobble, no two-phase seam.
SharedElement does not apply a default flex or size. Pass style whenever the wrapper sits inside a flex container that doesn't dictate child size:
<SharedElement style={{ flex: 1 }}>...</SharedElement>
<SharedElement style={{ width: 80, height: 80 }}>...</SharedElement>This ensures measureInWindow returns the actual rendered frame rather than a collapsed or stretched wrapper.
// app/_layout.tsx
<Stack.Screen
name="details/[id]"
options={{ animation: 'none', gestureEnabled: false }}
/>animation: 'none' lets the overlay take over visual continuity. The library handles its own animation; the navigator should stay out of the way.
- Tags match? — source
href(orid) must equal destinationpathname(orid). - Provider mounted? —
SharedElementProvidermust wrap the navigator. - Visible at mount? — destinations rendered far below the fold (long ScrollView) may measure to off-screen coordinates. Place the destination above the fold or scroll it into view before navigation.
- borderRadius parity — set
borderRadiuson both sides; the library animates from one to the other. - Aspect ratio — for
morph, similar aspect ratios produce the smoothest result. Useshapewhen the aspect ratio changes drastically (rectangle → circle).
- Wrapper has a layout? — provide
styleso the wrapper has the same size as its child. A wrapper that collapses to 0×0 will fail to measure and produce a flicker. - Multiple SharedElements share the same tag? — that is supported; the most recently mounted one is captured. If you want a specific instance, use an explicit
idand override on both sides.
The library polls for 800 ms. For slow-mounting screens, consider lazy-loading or preloading the destination component.
| Feature | @le-sourcier/shared-element | react-native-shared-element | Reanimated SET |
|---|---|---|---|
| Dependencies | None | Native build | React Navigation |
| Expo Go support | ✅ | ❌ | ❌ |
| Bundle size | ~5 KB | ~50 KB | N/A |
| Multi-element | ✅ (pub/sub registry) | Partial | Partial |
| Maintenance | Active | Abandoned | Experimental |
Built by Le-Sourcier.
- GitHub: Le-Sourcier
- LinkedIn: linkedin.com/in/yao-logan
MIT.
- Pub/sub hidden-tag store via
useSyncExternalStore— eliminates the "element disappears" race when a screen remounts mid-transition. - Stable registrations: registry no longer churns on parent re-render.
shapepreset uses linear interpolation for a wobble-free stadium morph.- Wrapper no longer imposes
flex: 1; passstyleexplicitly. - Multi-shared-element coexistence (multiple tags per screen, multiple registrations per tag) is now a documented, tested guarantee.
- Initial release.
- Forward and reverse transitions.
- Expo Router auto-key convention.
- Custom spring configuration.