Creating the iMessage Card Stack Animation: The Interactive Layer

Lorenzo Migliorero - Oct 29 - - Dev Community

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
  ...
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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]));
});
Enter fullscreen mode Exit fullscreen mode

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]));
  }
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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

<motion.article
  ...
  _dragX={dragX}
  drag={!supportTouch ? "x" : undefined}
>
Enter fullscreen mode Exit fullscreen mode

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,
  });
});
Enter fullscreen mode Exit fullscreen mode

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}
>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
<motion.article
  ...
  dragTransition={{
    power: 0.4,
    timeConstant: 90,
    modifyTarget: getSnappedTarget,
  }}
>
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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()));
  }
};
Enter fullscreen mode Exit fullscreen mode

Finally, let's disable CSS snap during dragging:

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

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.

. .