Creating the iMessage Card Stack Animation: Part 2 — The Interactive Layer

Lorenzo Migliorero
6 min readOct 29, 2024

--

This is the second and last article about a Series called Creating the iMessage Card Stack Animation, which explains the journey behind developing a Card Stack component, initially inspired by Natan Smith's tweet.

The final result can be checked here: https://card-stack.lrnz.work

The challenge was quite interesting and complex, so I decided to split the article in two:

Introduction

The first article explored how to animate the cards through a timeline and control their playback with a MotionValue.

In this article, we’ll complete the component by controlling the MotionValue with fundamental interactions like scrolling and dragging across desktop and touch devices.

It’s a dive into my challenges in simultaneously supporting native CSS Snap with drag on touch and non-touch devices.

Leveraging Browser Features

To achieve the best possible experience, I used CSS Snap with native scrolling. Virtualizing the scroll would have given me more control but would sacrifice accessibility and responsiveness, especially on sensitive devices like touchpads or MagicMouse.

Non-Touch Devices

Example of a drag on non-touch devices

On non-touch devices, the container will scroll vertically, with added support for horizontal dragging. I opted for vertical scrolling as it aligns with the natural scrolling direction, and I do not anticipate adding any additional elements in this context. The layout of the cards suggests horizontal dragging.

Touch Devices

Example of a drag on touch devices

On touch devices, the container will scroll horizontally, so there’s no need for virtualization since the browser manages scroll and drag directly.

CSS Snap Implementation

The main idea is to keep the cards centered using position: absolute within the viewport, while the slides will scroll and control the timeline.

If an element is set to position: absolute and no parent elements have position: relative|absolute|fixed, then the absolutely positioned element will be positioned relative to the viewport.

This approach limits the component from having other elements below it, because the slides are “absolute” to the viewport itself. One alternative is to set the Snap component to position: relative and position the cards outside the scrollable container using position: absolute or to enable drag without scroll on desktop. However, these adjustments aren’t necessary for our current setup.

First, let’s wrap the Cards within the Snap component:

<Snap>
<Card progress={progress} index={0}></Card>
<Card progress={progress} index={1}></Card>
<Card progress={progress} index={2}></Card>
<Card progress={progress} index={3}></Card>
<Card progress={progress} index={4}></Card>
<Card progress={progress} index={5}></Card>
</Snap>

Then, map the slides within a snap-item div:

<div className="snap">
{Children.map(children, (child, key) => (
<div className="snap-item" key={key}>
{child}
</div>
))}
</div>

Finally, center the slides within the viewport, and add the snap:

.snap {
width: 100dvw;
height: 100dvh;
scroll-snap-type: y mandatory;
overflow: auto;
}

.snap-item {
height: 100%;
scroll-snap-align: center;
display: grid;
place-items: center;
}

@media (pointer: coarse) and (hover: none) {
.snap {
display: flex;
scroll-snap-type: x mandatory;
}
.snap-item {
flex-shrink: 0;
width: 100%;
}
}

.card {
position: absolute;
margin: auto;
inset: 0;
...
}

At this point, we have a vertically scrollable container on non-touch devices and a horizontally scrollable container on touch devices while the cards remain centered. We can now proceed with mapping the MotionValue to its scroll progress.

It’s time to move those cards!

Progress Mapping

The next step is to sync the scroll progress with the progress MotionValue.
Let’s update the Snap component by adding the progress prop.

<Snap progress={progress}>
...
</Snap>

One advantage of having full-size slides is that they’re all the same size, making mapping very simple. The progress value is obtained by mapping the scroll range [0, 1] to [0, length — 1]:

const length = Children.count(children);

const { scrollYProgress } = useScroll({
container: $wrapperRef,
});

useMotionValueEvent(scrollYProgress, "change", (latest) => {
progress.set(transform(latest, [0, 1], [0, length - 1]));
});

We can simply use scrollXProgress for touch device support while keeping the exact mapping.

const length = Children.count(children);
const supportTouch = useTouch();

const { scrollYProgress, scrollXProgress } = useScroll({
container: $wrapperRef,
});

useMotionValueEvent(
supportTouch ? scrollXProgress : scrollYProgress,
"change",
(latest) => {
progress.set(transform(latest, [0, 1], [0, length - 1]));
}
);

At this point, we assume the initial MotionValue will always be 0.
If we want to start from a different slide, we’ll need to update the scroll position when the component mounts:

useEffect(() => {
const { clientWidth, clientHeight } = $wrapperRef.current;

$wrapperRef.current[supportTouch ? "scrollLeft" : "scrollTop"] = transform(
progress.get(),
[0, length - 1],
[0, (supportTouch ? clientWidth : clientHeight) * (length - 1)]
);
}, [length, progress, supportTouch]);

Note that if the progress value changes externally after the first render, the scroll won’t update. This is okay for this case, as I don’t plan on altering it with other components, like pagination or external controls.

Drag Support

The most time-consuming part was adding drag support. Thanks to Framer Motion, the implementation of drag itself is straightforward. The main challenge was making it coexist with CSS Snap.

Since the container scrolls natively, I don’t need to apply transforms, but I do need to use the drag physics to the scroll.

Checking Framer Motion’s types, I found two undocumented props, _dragX and _dragY, which apply drag to the MotionValues they receive without updating the element transform.

That’s what I was looking for, great! Let’s update the Snap component to support _dragX and use it to update the scroll.

Isolate the drag physics

Thanks to the _dragX prop, all the drag physics will be reflected in the dragX MotionValue. We just need to update the scrollTop based on it:

const dragX = useMotionValue(0);

useMotionValueEvent(dragX, "change", (latest) => {
$wrapperRef.current.scrollTop = Math.abs(latest);
});

When the user starts dragging, it’s important to sync the current scrollTop. Otherwise, we would start from the previous value. The jump method cancels any current active animation:

const onMouseDown = () => {
dragX.jump(-$wrapperRef.current.scrollTop);
};

Since we don’t need any drag on mobile, let’s simply disable the prop there:

<motion.article
...
_dragX={dragX}
drag={!supportTouch ? "x" : undefined}
onMouseDown={onMouseDown}
>

And yeah, it’s as simple as this!

Calculate drag constraints

Since the drag will be applied to the scrollable element, we need to calculate the constraints manually. They will be equal to the maximum scrollable value:

const [dragConstraints, setDragConstraints] = useState();

useResizeObserver($wrapperRef, () => {
setDragConstraints({
left: -(
$wrapperRef.current.scrollHeight - $wrapperRef.current.clientHeight
),
right: 0,
});
});

Since any movement outside the normal scroll range wouldn’t be supported by any browser, we need to remove the drag elastic:

<motion.article
...
dragElastic={0}
dragConstraints={dragConstraints}
>

Snap points

Finally, let’s add a snap-to-grid-like feature with the modifyTarget function:

const getSnappedTarget = (value) =>
Math.round(value / $wrapperRef.current.clientHeight) *
$wrapperRef.current.clientHeight;
<motion.article
...
dragTransition={{
power: 0.4,
timeConstant: 90,
modifyTarget: getSnappedTarget,
}}
>

Almost there! Unfortunately, there’s an issue. The progress is updated stepwise from 0 to 1, with no fractions.

Scrolling the container programmatically is impossible as long as scroll-snap-type: y mandatory is active on the wrapper.

IsDragActive

Drag and CSS Snap can’t coexist, so I need to remove the scroll-snap type while the user drags and re-enable it only when the drag animation completes:

const [isDragActive, setIsDragActive] = useState(false);

const onMouseDown = () => setIsDragActive(true);

useMotionValueEvent(dragX, "animationComplete", () => setIsDragActive(false));

useEffect(() => {
if (isDragActive) {
dragX.jump(-$wrapperRef.current.scrollTop);
}
}, [isDragActive, dragX]);

To handle an edge case, if a user interrupts a drag animation by quickly clicking and releasing, we should snap to the nearest item before reenabling the native snap:

const onMouseUp = () => {
if (dragX.getVelocity() === 0) {
animate(dragX, getSnappedTarget(dragX.get()));
}
};

Finally, let’s disable CSS snap during dragging:

<motion.article
...
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
style={
isDragActive
? {
overflow: "hidden",
scrollSnapType: "none",
}
: undefined
}
>

Conclusion

This project was a lot of fun to work on. The component still has room for improvement, but the goal of keeping native CSS Snap with drag has been achieved, providing a smooth, satisfying scroll experience.

It is worth mentioning that some elements of the initial prototype, such as the background, shadows, and detail page, were intentionally excluded from this series, as they were not present in the original tweet and were not directly relevant to the main topic.

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

--

--

Lorenzo Migliorero

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