Creating the iMessage Card Stack Animation: The Timeline Design

Lorenzo Migliorero - Oct 16 - - Dev Community

I was recently captured by this tweet from Natan Smith. The interaction looks super cool, and I wondered which challenges could hide a porting in Framer Motion and React.

So, I decided to dive into it, and that's the final result:
https://card-stack.lrnz.work


Initial approach

After analyzing the video, as in the original, I created a distinct separation between the visual layer — how the cards appear and animate — and the interactive layer — how users manipulate the cards.

This is a mandatory step. Without splitting the complexity from the beginning, I wouldn’t ever see the end here.

In this article, I’ll cover the visual layer, represented by the animation timeline.

Also, each card follows the same timeline but starts at a different position. Once the main timeline is set, I can apply it to each card by adjusting its starting position based on its distance from the front, a concept also covered in the original tweet.

Designing the Timeline

The first step is understanding how properties are distributed along a timeline and mapping them to a fixed range.
After a bit of trial and error, I came up with these values for a timeline range from -2 to 2:

CSS Properties mapped to a range of -2 | 2<br>

And here is the framer-motion equivalent:

const progress = useMotionValue(0);

const x = useTransform(
  progress,
  [-2, -1, 0, 1, 2],
  ["24%", "12%", "0%", "-12%", "-24%"],
);

const z = useTransform(
  progress,
  [-2, -1, 0, 1, 2],
  [-340, -170, 0, -170, -340]
);

const rotateZ = useTransform(
  progress,
  [-2, -1, 0, 1, 2],
  [4.8, 2.4, 0, -2.4, -4.8]
);

const rotateY = useTransform(
  progress,
  [-2, -1.5, -1, -0.5, 0, 0.5, 1.5, 2],
  [0, 20, 0, 20, 0, -20, 0, -20]
);
Enter fullscreen mode Exit fullscreen mode

Cool! That's a good start.

The animation works, but the timeline has a fixed range and works only with one card, so it will be a long journey.
Initially, I didn't think this would be a problem since I didn't plan to have more than ten cards, but…

Scaling the Timeline

What if I wanted to scale this up? This quickly becomes a mess:

const rotateZ = useTransform(
  progress,
  [-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
  [24, 21.6, 19.2, 16.8, 14.4, 12, 9.6, 7.2, 4.8, 2.4, 0, -2.4, -4.8, -7.2, -9.6, -12, -14.4, -16.8, -19.2, -21.6, -24]
);
Enter fullscreen mode Exit fullscreen mode

I improved it using a factory function:

const step = 10;
const n = 2.4;

const rotateZ = useTransform(
  progress,
  Array.from({ length: 2 * n + 1 }, (_, i) => i - n),
  Array.from({ length: 2 * n / step + 1 }, (_, i) => i * step - n)
);
Enter fullscreen mode Exit fullscreen mode

Much better! But it's still constrained. Fortunately, I found that Framer Motion supports unclamped transforms:

const rotateZ = useTransform(progress, [0, 1], [0, -2.4], {
  clamp: false,
});
Enter fullscreen mode Exit fullscreen mode

This allows the range to be mapped infinitely without any factory function.
Exactly what I need!

Distance from the front

As I mentioned earlier, the logic for one card can easily be applied to the others, considering each initial position.

If a card has an initial position of 1, its first timeline frame will be 1.
If it starts as 2, it will be 2, and so on.

I, therefore, added the index prop to the card component and created the distanceFromFront motion value, subtracting the index from the progress:

<Card progress={progress} index={0} />
<Card progress={progress} index={1} />
<Card progress={progress} index={2} />
<Card progress={progress} index={3} />
<Card progress={progress} index={4} />
Enter fullscreen mode Exit fullscreen mode
const distanceFromFront = useTransform(progress, (latest) => latest - index);

const x = useTransform(distanceFromFront, [-1, 0, 1], ["12%", "0%", "-12%"], {
  clamp: false,
});

...
Enter fullscreen mode Exit fullscreen mode

This should ensure that every card follows the same timeline, just shifted by the initial position:

Finally! However, it feels weird. There’s something off with it. What could it be?

Improving the Rotation Y

While testing the prototype, the rotation y was off.
This property should behave differently than the others.

*It should be mapped to the direction, not the progress!
*

Ideally, when the user swipes to the left, the rotation should go from 0 to -20 to 0 again; the opposite happens when the user swipes to the right.

I need a direction variable based on the floored value of progress (rounded down to the nearest integer) using Math.floor. The default direction should be set to 1 and only change at the start of each timeline step, preventing any Y-axis rotation changes while progress is between whole numbers.

Additionally, the direction should reset to its default when the user reaches either the beginning or the end of the stack, or when stopping on a specific slide:

const progress = useMotionValue(0);
const progressStep = useTransform(progress, (latest) => Math.floor(latest));
const [direction, setDirection] = useState(1);

useMotionValueEvent(progressStep, "change", (latest) => {
  const previous = progressStep.getPrevious();
  if (previous === undefined) return;

  setDirection(previous > latest ? -1 : 1);
});

useMotionValueEvent(progress, "change", (latest) => {
  if (latest % 1 === 0) {
    setDirection(1);
  }
});

...

<Card progress={progress} direction={direction} />
Enter fullscreen mode Exit fullscreen mode

And then, I adapted the rotateY to be based on the direction, rather than on the progress:

const rotateY = useTransform(distanceFromFrontABS, (value) => {
  return (
    transform(value % 1, [0, 0.5, 1], [0, -20, 0]) *
    direction
  );
});
Enter fullscreen mode Exit fullscreen mode

Now, yes, it feels much more natural!

Handling the “First” Card Animation

So far, so good. The code is clean, and the timeline has no range constraint. However, the result still needs to include something. The animation of the card at the front differs from the others.

I need a condition to identify when a card reaches the front and is leaving, which we will call isFirst for simplicity:

A card can be considered isFirst if:

  • Its distance from the front was less than 0 and became greater than 0
  • Its distance from the front was greater than 0 and became less than 0

A card is no longer considered isFirst if:

  • Its distance from the front is less than -1
  • Its distance from the front is greater than 1
const isFirst = useState(progress.get() < 0.5);

useMotionValueEvent(progress, 'change', (latest) => {
  const previous = progress.getPrevious();

  if (previous === undefined) return;

  /* Check when the progress sign changes */
  if (latest * previous <= 0) {
    setIsFirst(true);
  }

  /* When the progress becomes greater than 1 or less than -1 */
  if (Math.abs(latest) >= 1) {
    setIsFirst(false);
  }
});
Enter fullscreen mode Exit fullscreen mode

Finally, depending on whether a card is isFirst, I adjusted some properties and added the scale:

const x = useTransform(
  distanceFromFront,
  [-1, -0.5, 0, 0.5, 1],
  isFirst
    ? ["12%", "77%", "0%", "-77%", "-12%"]
    : ["12%", "5%", "0%", "-5%", "-12%"],
  {
    clamp: false,
  }
);

const rotateY = useTransform(
  progress,
  [-1, -0.5, 0, 0.5, 1],
  isFirst
    ? [0, 20, 0, -20, 0]
    : [0, 45, 0, -45, 0]
);

const scale = useTransform(
  distanceFromFrontABS,
  [0, 0.5, 1],
  isFirst ? [1, 0.95, 1] : [1, 1, 1]
);
Enter fullscreen mode Exit fullscreen mode

To fix a flick caused by z-indexes, I slightly changed the zIndex transform to keep the first card a little bit more on the front:

const zIndex = useTransform(
  distanceFromFront,
  [-2, -1, 0, 0.7, 2],
  [-2, -1, 0, 0, -2],
  {
    clamp: false,
  }
);
Enter fullscreen mode Exit fullscreen mode

And here we go! What a challenge!
The component is now ready to be connected with the interactive layer.


Thanks to Nate Smith, Daniel Destefanis, and Paul Noble for inspiring this idea.

. .