Framer Motion을 이용해 무한 스크롤 캐러셀(Infinite Scrolling Carousel) 구현하기

Sara Jo·2024년 9월 30일
post-thumbnail

English with Suits 홈 화면을 좀 더 깔끔하고 예쁘게 바꾸고싶었다.

아이디어를 고민하면서 레퍼런스를 찾아보다가 각 에피소드별 이미지가 캐러셀 형태로 이동하는 애니메이션을 구현하기로 했다😃.

두 줄의 캐러셀이 아래에서 위쪽으로 계속해서 움직이고,
두 줄은 Y축 위치가 서로 교차되며,
오른쪽 캐러셀은 끝부분이 살짝 잘린 형태로 구현하고자 설계했다.


실행 화면

⬇️⬇️⬇️
English with Suits 홈 화면

위 이미지는 GIF 파일이라 애니메이션이 중간에 끊겨보이지만, 실제로는 자연스럽게 연결되며 무한재생된다.


이 영상을 참고하여 구현했고, Framer Motion을 활용하여 무한 스크롤 캐러셀을 구현한 방법에 대해 자세히 정리해보고자 한다.


1. 패키지 설치

애니메이션을 위한 framer-motion
요소의 크기 측정을 위한react-use-measure 패키지를 사용했다.

npm install framer-motion react-use-measure

2. ImageCard 컴포넌트 생성

각 이미지를 렌더링ImageCard 컴포넌트를 motion.div를 사용하여 생성했다.

import { motion } from "framer-motion";
import Image from "next/image";
import styles from "./ImageCard.module.css";

export default function ImageCard({ image }: { image: string }) {
  return (
    <motion.div className={styles.container}>
      <Image
        src={image}
        alt={image}
        fill
        style={{ objectFit: "cover" }}
      ></Image>
    </motion.div>
  );
}

3. 이미지 캐러셀 렌더링

위에서 생성한 ImageCard 컴포넌트를 사용하여 애니메이션이 아직 적용되지 않은 이미지 캐러셀을 아래와 같이 두 줄 렌더링 해주었다.

// 첫번째, 두번째 캐러셀 이미지
const images1 = Array.from({ length: 12 }, (_, i) => `/image/home/S1/${i + 1}.jpg`);
const images2 = Array.from({ length: 12 }, (_, i) => `/image/home/S2/${i + 1}.jpg`);

export default function Home() {
  
  ...
  
  return (
    <main className={styles.main}>
  		...

		{/* 첫번째 캐러셀 */}
		<motion.div className={styles.imageContainer}>
          {[...images1].map((item, idx) => (
            <ImageCard image={item} key={idx} />
          ))}
        </motion.div>

		{/* 두번째 캐러셀 */}
        <motion.div className={styles.imageContainer}>
          {[...images2].map((item, idx) => (
            <ImageCard image={item} key={idx} />
          ))}
        </motion.div>
	</main>
  );
}

4. 이미지 높이 측정 및 애니메이션 값 설정

useMeasure 훅을 사용하여 이미지 컨테이너의 높이를 측정하고,
useMotionValue를 통해 y축의 애니메이션 값을 설정한다.

let [heightRef, { height }] = useMeasure();
const yTranslation = useMotionValue(0);

1. useMeasure

useMeasurereact-use-measure 라이브러리에서 제공하는 훅으로, 특정 요소의 크기와 위치를 동적으로 측정할 수 있도록 도와준다. 이 훅을 사용하면 DOM 요소의 크기를 가져오고, 변경사항에 따라 리렌더링을 트리거할 수 있다.

import useMeasure from "react-use-measure";

const [ref, { height, width }] = useMeasure();
  • ref: 측정하고자 하는 DOM 요소에 연결할 수 있는 참조(ref)이다. 이를 통해 요소의 크기 및 위치를 측정한다.
  • { height, width }: 측정된 요소의 높이와 너비를 반환한다. 이 값들은 요소가 렌더링될 때마다 자동으로 업데이트된다.

useMeasure를 사용하여 주로 캐러셀의 이미지 컨테이너와 같은 요소의 높이를 측정하며, 이렇게 측정된 높이는 애니메이션의 종료 위치를 결정하는 데 사용된다. 예를 들어, 무한 스크롤 캐러셀에서 이미지가 스크롤하는 최종 위치를 설정하는 데 중요하다.


2. useMotionValue

useMotionValueFramer Motion에서 제공하는 훅으로, 애니메이션 상태를 관리하는 데 사용된다. 이 훅을 통해 특정 값(예: 위치, 크기 등)을 애니메이션화할 수 있.

import { useMotionValue } from "framer-motion";

const yTranslation = useMotionValue(0);
  • yTranslation: y축 위치를 나타내는 MotionValue 객체이다. 초기값을 0으로 설정하고, 이후 이 값을 애니메이션을 통해 변경할 수 있다.

useMotionValue를 사용하여 애니메이션화할 값(예: y축 위치)을 설정한다. 캐러셀에서 이미지를 스크롤할 때 이 값을 업데이트하여 무한 스크롤 효과를 만들어낼 수 있다. 예를 들어, 애니메이션이 진행되면서 yTranslation의 값이 변화하여 이미지가 위로 스크롤되는 효과를 구현할 수 있다.


5. 애니메이션 설정

useEffect 훅을 사용하여 캐러셀의 애니메이션을 설정한다.
이미지가 아래에서 위 방향으로 이동하며 무한 루프를 돌도록 설정했다.

import { animate } from "framer-motion";

useEffect(() => {
  let controls;
  let finalPosition = -height / 2 - 8;

  controls = animate(yTranslation, [0, finalPosition], {
    ease: "linear",	// 애니메이션이 일정한 속도로 진행
    duration: 24,	// 애니메이션이 완료되는 데 걸리는 시간(24초)
    repeat: Infinity,	// 애니메이션 무한 반복
    repeatType: "loop",	// 애니메이션이 반복될 때 다시 처음 위치로 되돌아가지 않고 연결
    repeatDelay: 0,	// 애니메이션 반복 사이의 지연 시간 설정
  });

  return () => controls.stop;
}, [yTranslation, height]);
  • finalPosition: 이미지의 최종 위치를 결정한다. 현재 높이의 절반을 기준으로 위쪽으로 이동한다.
  • animate: yTranslation을 애니메이션하여 무한 스크롤 효과를 만든다.
  • yTranslation: y축 위치를 나타내며, 애니메이션이 진행됨에 따라 이 값이 변화한다.
  • [0, finalPosition]: 애니메이션의 시작 값과 끝 값을 배열로 지정한다. 이 경우 0에서 finalPosition으로 이동
    • 0: 애니메이션의 시작 위치 (기본 위치)
    • finalPosition: 애니메이션의 종료 위치로, 여기서는 이미지 캐러셀의 높이에 따라 계산된 값

6. 애니메이션 적용

이제 멈춰 있는 캐러셀에 애니메이션을 적용하면 된다!

코드 반복을 피하기 위해 map으로 두 줄의 캐러셀을 렌더링하고, 위에서 설정한 애니메이션을 적용해주었다.

{[images1, images2].map((images, index) => (
  <motion.div
  	key={index}
	className={`${styles.imageContainer} ${styles[`imageContainer${index + 1}`]}`}
	ref={heightRef}
	style={{ y: yTranslation }}
  >
		{[...images, ...images].map((item, idx) => (
        	<ImageCard image={item} key={idx} />
        ))}
  </motion.div>
))}
  • 각각의 motion.div애니메이션된 y값을 적용하여 스크롤 효과를 만든다.
  • images1images2 배열은 두 번씩 렌더링하여 무한 스크롤이 가능하도록 했다.

7. 스타일링

각 이미지 컨테이너의 위치와 간격을 정의하고, 두 줄의 캐러셀 y좌표가 서로 엇갈리도록 배치하였다.

.imageContainer {
  position: absolute;
  gap: 2rem;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
}

.imageContainer1 {
  right: 20rem;
  top: 0;
}

.imageContainer2 {
  right: -2rem;
  top: 7rem;
}

💻 전체 코드

"use client";

import styles from "./page.module.css";
import { useEffect, useState } from "react";
import { animate, motion, useAnimation, useMotionValue } from "framer-motion";
import ImageCard from "./_components/ImageCard/ImageCard";
import useMeasure from "react-use-measure";

const images1 = Array.from(
  { length: 12 },
  (_, i) => `/image/home/S1/${i + 1}.jpg`
);
const images2 = Array.from(
  { length: 12 },
  (_, i) => `/image/home/S2/${i + 1}.jpg`
);

export default function Home() {
  ...
  
  const buttonControls = useAnimation();
  let [heightRef, { height }] = useMeasure();
  const yTranslation = useMotionValue(0);

  ...

  useEffect(() => {
    let controls;
    let finalPosition = -height / 2 - 8;

    controls = animate(yTranslation, [0, finalPosition], {
      ease: "linear",
      duration: 24,
      repeat: Infinity,
      repeatType: "loop",
      repeatDelay: 0,
    });

    return () => controls.stop();
  }, [yTranslation, height]);

  return (
    <main className={styles.main}>
      ...

      <div className={styles.rightSide}>
        {[images1, images2].map((images, index) => (
          <motion.div
            key={index}
            className={`${styles.imageContainer} ${
              styles[`imageContainer${index + 1}`]
            }`}
            ref={heightRef}
            style={{ y: yTranslation }}
          >
            {[...images, ...images].map((item, idx) => (
              <ImageCard image={item} key={idx} />
            ))}
          </motion.div>
        ))}
      </div>
    </main>
  );
}

0개의 댓글