This is the second and last article about a Series called Creating the iMessage CardStack Animation, which explains the journey behind developing a CardStack component, initially inspired by Natan Smith's tweet.
The final result can be checked here: https://card-stack.lrnz.work
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
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
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 don’t need any custom drag on mobile, so let’s simply disable the prop there:
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}
>
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 drag 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.