Logo

dev-resources.site

for different kinds of informations.

Creating the iMessage Card Stack Animation: The Interactive Layer

Published at
10/29/2024
Categories
designengineer
animation
framermotion
react
Author
lorenzomigliorero
Author
17 person written this
lorenzomigliorero
open
Creating the iMessage Card Stack Animation: The Interactive Layer

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.

framermotion Article's
30 articles in total
Favicon
Building a food simulation game with Next.js and generative design
Favicon
How to Create a Flipping Card Animation Using Framer Motion
Favicon
State-of-the-Art Web Design with Remix, Tailwind, and Framer Motion
Favicon
How I Created a Stunning Portfolio with Next.js, Tailwind CSS, and Framer Motion
Favicon
Creating the iMessage Card Stack Animation: The Interactive Layer
Favicon
Creating the iMessage Card Stack Animation: The TimelineĀ Design
Favicon
How to build awesome website with stunning animation?
Favicon
Adding motion to 3D models with Framer Motion and Three.js
Favicon
Creating a Smooth Animated Menu with React and Framer Motion
Favicon
My-Portfolio
Favicon
Watch Out For Broken Links, 404 Page With Framer Motion, TailwindCSS and NextJs
Favicon
Could not find a declaration file for module framer-motion Error in Next.js 14
Favicon
How to add Animations and Transitions in React
Favicon
Creating a text writing animation in React using Framer-Motion
Favicon
Unveiling My Portfolio Website: A Fusion of Tech and Creativity šŸš€
Favicon
React developer portfolio template use framer motion to animate components
Favicon
Building a mini casual game with Next.js
Favicon
Creating a Multi-Level Sidebar Animation with Tailwind and Framer Motion in React
Favicon
Easy Animated Tabs in ReactJS with Framer Motion
Favicon
A Tinder-like card game with Framer-Motion
Favicon
Elevating Web Design with Framer Motion: Bringing Your Website to Life
Favicon
Animating the Web: Route Transitions in Next.js with Framer Motion
Favicon
Enhancing Form Usability with Framer Motion: A Guide to Animated, Chunked Form Transitions
Favicon
How to Build a fully accessible Accordion with HeadlessUI, Framer Motion, and TailwindCSS
Favicon
Build Portfolio Theme in Tailwind CSS & Next.js
Favicon
How to create Scroll-Linked Animations with React and Framer Motion šŸ’ƒšŸ»šŸ•ŗšŸ»
Favicon
How to build 3 simple animation with framer motion
Favicon
How to create an awesome navigation menu using chakra-UI and framer-motion.
Favicon
Make a slide open envelope
Favicon
Animate everything with Framer Motion

Featured ones: