mrnb
mrnb

Reputation: 3

framer-motion drag carousel not working properly

I try to build a simple Image Carousel with the help of framer-motion.

I want to use buttons and also drag to control the slides. It works just fine, but when i overshoot the slider on the last slide, it animates back to the start and mess up completely with the index logic.

Here is my code:

"use client";
import { useState } from "react";
import { AnimatePresence, motion, MotionConfig, PanInfo } from "framer-motion";

let images = [
  "/images/carousel/1.jpeg",
  "/images/carousel/2.jpeg",
  "/images/carousel/3.jpeg",
  "/images/carousel/4.jpeg",
  "/images/carousel/5.jpeg",
];

export default function App() {
  let [index, setIndex] = useState(0);

  const dragEndHandler = (dragInfo: PanInfo) => {
    const draggedDistance = dragInfo.offset.x;
    const swipeThreshold = 50;
    if (draggedDistance > swipeThreshold) {
      console.log("swipe detection: ", "prev");
      index > 0 && setIndex(index - 1);
    } else if (draggedDistance < -swipeThreshold) {
      console.log("swipe detection: ", "next");
      index + 1 < images.length && setIndex(index + 1);
    }
  };

  return (
    <MotionConfig transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}>
      <div className="relative overflow-hidden">
        <motion.div
          animate={{ x: `-${index * 100}%` }}
          className="flex"
          drag="x"
          dragElastic={1}
          dragConstraints={{ left: 0, right: 0 }}
          onDragEnd={(_, dragInfo: PanInfo) => dragEndHandler(dragInfo)}
        >
          {images.map((image) => (
            <div className="aspect-[4/5] w-full min-w-full">
              <img
                key={image}
                src={image}
                className="object-cover min-w-full pointer-events-none"
              />
            </div>
          ))}
        </motion.div>
        <AnimatePresence initial={false}>
          {index > 0 && (
            <motion.button
              initial={{ opacity: 0 }}
              animate={{ opacity: 0.7 }}
              exit={{ opacity: 0, pointerEvents: "none" }}
              whileHover={{ opacity: 1 }}
              className="absolute left-2 top-1/2 -mt-4 flex h-8 w-8 items-center justify-center rounded-full bg-white"
              onClick={() => setIndex(index - 1)}
            >
              <div className="h-6 w-6">Prev</div>
            </motion.button>
          )}
        </AnimatePresence>

        <AnimatePresence initial={false}>
          {index + 1 < images.length && (
            <motion.button
              initial={{ opacity: 0 }}
              animate={{ opacity: 0.7 }}
              exit={{ opacity: 0, pointerEvents: "none" }}
              whileHover={{ opacity: 1 }}
              className="absolute right-2 top-1/2 -mt-4 flex h-8 w-8 items-center justify-center rounded-full bg-white"
              onClick={() => setIndex(index + 1)}
            >
              <div className="h-6 w-6">Next</div>
            </motion.button>
          )}
        </AnimatePresence>
      </div>
    </MotionConfig>
  );
}

I tried to use dragSnapToOrigin={false} but this doesn't work with elastic and such. I think the issues occurs from the use of elastic and how i determine the drag direction. But i can't solve the problem here. Any help will be appreciated :)

Upvotes: 0

Views: 780

Answers (1)

Rico
Rico

Reputation: 2057

The issue comes from the width of your motion.div and it's dragConstraints. I added some borders to your motion.div and noticed it was only one slide wide. This was causing the dragConstraints to not work how you would expect. When you get to the end of your carousel and drag right, the constraints kick in and resets the transform style to "none", so we see the carousel zoom back to the first slide, even though the index did not change. In my experience with framer motion, I've had the most success getting dragContraints to behave how I think they should behave by using a ref instead of setting the top, left, etc.

These are the things that need to be updated to get the desired result:

  1. Change the dragConstraints to use a ref that is attached to the parent container.
  2. Make sure the slides container is as wide as all of the slide's widths combined. If we want each slide to be 100% of the slide container's width, we need the slide container to be (images.length * 100)% wide.
  3. Update the x offset of the container to be one image's width (100 / images.length)% times the index.
  4. Remove the min-w-full class from the slide.

Here is the relevant code snippet from this working example I created.

    const constraintsRef = useRef(null);
    
    ...

    {/* ↓ Parent Container */}
    <div
      // Added ref to Parent Container so we can use
      // it's bounding box as dragConstraints
      // https://www.framer.com/motion/gestures/###dragconstraints
      ref={constraintsRef}
      className="relative overflow-hidden"
    >
      {/* ↓ Draggable Container */}
      <motion.div
        style={{
          // Set the width to be 100% of the Parent Container
          // times the number of slides in the carousel. This
          // helps make sure the dragConstraints work as expected.
          width: `${images.length * 100}%`,
        }}
        animate={{
          // Update the x offset to be one slides width. Since
          // the width of the Draggable Container is
          // images.length * 100, each slide's width is
          // 100 / images.length.
          x: `-${index * (100 / images.length)}%`,
        }}
        drag="x"
        dragElastic={1}
        dragConstraints={
          // Set the drag constraints to the Parent Container's
          // bounding box using a ref.
          // https://www.framer.com/motion/gestures/###dragconstraints
          constraintsRef
        }
        onDragEnd={(_, dragInfo: PanInfo) => dragEndHandler(dragInfo)}
        className="flex"
      >
        {/* ↓ Slides map */}
        {images.map((image, imageIndex) => (
          <div
            key={image + imageIndex}
            // Removed min-w-full. It was causing the slide
            // to be too large. 
            className="aspect-[4/5] w-full"
          >
            <img
              src={image}
              className="object-cover min-w-full pointer-events-none"
            />
          </div>
        ))}
      </motion.div>
      ...
    </div>

Upvotes: 1

Related Questions