Creating the iMessage Card Stack Animation: Part 1 — The Timeline Design

Lorenzo Migliorero
6 min readOct 16, 2024

--

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

Given the complexity of this prototype, I decided to split the article into two parts:

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

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]
);

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]
);

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)
);

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,
});

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} />
const distanceFromFront = useTransform(progress, (latest) => latest - index);

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

...

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} />

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
);
});

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);
}
});

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]
);

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,
}
);

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.

--

--

Lorenzo Migliorero
Lorenzo Migliorero

Written by Lorenzo Migliorero

Senior Frontend Engineer based in Amsterdam dedicated to crafting clean, accessible, and visually engaging user interfaces.