
English with Suits 홈 화면을 좀 더 깔끔하고 예쁘게 바꾸고싶었다.
아이디어를 고민하면서 레퍼런스를 찾아보다가 각 에피소드별 이미지가 캐러셀 형태로 이동하는 애니메이션을 구현하기로 했다😃.
두 줄의 캐러셀이 아래에서 위쪽으로 계속해서 움직이고,
두 줄은 Y축 위치가 서로 교차되며,
오른쪽 캐러셀은 끝부분이 살짝 잘린 형태로 구현하고자 설계했다.
⬇️⬇️⬇️
English with Suits 홈 화면

위 이미지는 GIF 파일이라 애니메이션이 중간에 끊겨보이지만, 실제로는 자연스럽게 연결되며 무한재생된다.
이 영상을 참고하여 구현했고, Framer Motion을 활용하여 무한 스크롤 캐러셀을 구현한 방법에 대해 자세히 정리해보고자 한다.
애니메이션을 위한 framer-motion 과
요소의 크기 측정을 위한react-use-measure 패키지를 사용했다.
npm install framer-motion react-use-measure
각 이미지를 렌더링할 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>
);
}
위에서 생성한 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>
);
}
useMeasure 훅을 사용하여 이미지 컨테이너의 높이를 측정하고,
useMotionValue를 통해 y축의 애니메이션 값을 설정한다.
let [heightRef, { height }] = useMeasure();
const yTranslation = useMotionValue(0);
useMeasureuseMeasure는 react-use-measure 라이브러리에서 제공하는 훅으로, 특정 요소의 크기와 위치를 동적으로 측정할 수 있도록 도와준다. 이 훅을 사용하면 DOM 요소의 크기를 가져오고, 변경사항에 따라 리렌더링을 트리거할 수 있다.
import useMeasure from "react-use-measure";
const [ref, { height, width }] = useMeasure();
ref: 측정하고자 하는 DOM 요소에 연결할 수 있는 참조(ref)이다. 이를 통해 요소의 크기 및 위치를 측정한다.{ height, width }: 측정된 요소의 높이와 너비를 반환한다. 이 값들은 요소가 렌더링될 때마다 자동으로 업데이트된다.useMeasure를 사용하여 주로 캐러셀의 이미지 컨테이너와 같은 요소의 높이를 측정하며, 이렇게 측정된 높이는 애니메이션의 종료 위치를 결정하는 데 사용된다. 예를 들어, 무한 스크롤 캐러셀에서 이미지가 스크롤하는 최종 위치를 설정하는 데 중요하다.
useMotionValue는 Framer Motion에서 제공하는 훅으로, 애니메이션 상태를 관리하는 데 사용된다. 이 훅을 통해 특정 값(예: 위치, 크기 등)을 애니메이션화할 수 있.
import { useMotionValue } from "framer-motion";
const yTranslation = useMotionValue(0);
yTranslation: y축 위치를 나타내는 MotionValue 객체이다. 초기값을 0으로 설정하고, 이후 이 값을 애니메이션을 통해 변경할 수 있다.useMotionValue를 사용하여 애니메이션화할 값(예: y축 위치)을 설정한다. 캐러셀에서 이미지를 스크롤할 때 이 값을 업데이트하여 무한 스크롤 효과를 만들어낼 수 있다. 예를 들어, 애니메이션이 진행되면서 yTranslation의 값이 변화하여 이미지가 위로 스크롤되는 효과를 구현할 수 있다.
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: 애니메이션의 종료 위치로, 여기서는 이미지 캐러셀의 높이에 따라 계산된 값이제 멈춰 있는 캐러셀에 애니메이션을 적용하면 된다!
코드 반복을 피하기 위해 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값을 적용하여 스크롤 효과를 만든다.images1과 images2 배열은 두 번씩 렌더링하여 무한 스크롤이 가능하도록 했다.각 이미지 컨테이너의 위치와 간격을 정의하고, 두 줄의 캐러셀 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>
);
}