RSLAB

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.

ExpoReanimated 4Gesture Handler

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.

CardExpand.tsx
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.

ExpandedOverlay.tsx
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.

ExpandedOverlay.tsx
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.

ExpandedOverlay.tsx
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.