Open source · React Native
Shared transitions are simpler than you think.
The App Store card transition, rebuilt from scratch and explained step by step. Native apps use shared elements everywhere and they feel like magic. They are not: measure a rect, mount a clone, drive one progress value. That is the whole recipe.
How to build it
1 · Measure the card
On tap, ask the card where it sits in window coordinates. That rect is the entire shared element contract: the clone starts there and must land back there.
const openCard = (card: Card) => {
ref.measureInWindow((x, y, width, height) => {
setOpen({ card, rect: { x, y, width, height } });
});
};2 · Mount a clone, interpolate the rect
The overlay renders the same card face and one progress value interpolates its rect from the slot to fullscreen. Corner radius travels from the card radius to the display radius, so fullscreen still hugs the phone corners.
const sheetStyle = useAnimatedStyle(() => ({
position: "absolute",
left: interpolate(p.value, [0, 1], [rect.x, 0]),
top: interpolate(p.value, [0, 1], [rect.y, 0]),
width: interpolate(p.value, [0, 1], [rect.width, screen.width]),
height: interpolate(p.value, [0, 1], [rect.height, screen.height]),
borderRadius: interpolate(p.value, [0, 1], [28, 55]),
}));3 · One progress drives everything
No bounce: a calm Apple-style curve. The body fade, the label offset and the background list zoom all read the same value, so nothing can desync and the close lands pixel perfect.
p.value = withTiming(1, {
duration: 440,
easing: Easing.bezier(0.32, 0.72, 0, 1),
});
const listStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 - p.value * 0.05 }],
opacity: 1 - p.value * 0.35,
}));4 · Drag down to dismiss
The pan shrinks the sheet while you hold it. Past a distance or a flick velocity, it releases into the close animation, reversed on the same curve. Under the threshold it settles back to fullscreen.
const pan = Gesture.Pan()
.onUpdate((e) => {
dragY.value = Math.max(0, e.translationY);
})
.onEnd((e) => {
if (dragY.value > 140 || e.velocityY > 900) close();
else dragY.value = withTiming(0, { duration: 220 });
});That is the whole trick. The full component with the list, the gestures and the safe area handling is in the repo.
Want more breakdowns like this?
I publish one micro-interaction at a time, each with its demo and the code explained. Drop your email and I'll send you the next ones.
No spam, no funnel. The source code is free either way.