React 무한 캐러셀 구현하기

서성원·2025년 2월 28일
1

리액트

목록 보기
26/26
post-thumbnail

저는 현재 학교에서 동아리 지원 관리 플랫폼을 제작 중입니다. 아래는 해당 프로젝트 깃허브 링크입니다.

moadong

이번에 메인페이지에 들어갈 캐러셀을 제작하게 되었습니다.
Typescript와 styled-component를 사용하였고 반응형과 무한 캐러셀 동작에 집중하였습니다.

기능 요구사항

  • 좌우 버튼을 눌러 배너를 슬라이드할 수 있어야 한다.
  • 자동으로 3초마다 배너가 이동한다.
  • 첫 번째 배너에서 이전 버튼을 누르면 마지막 배너로 이동해야 한다.
  • 마지막 배너에서 다음 버튼을 누르면 첫 번째 배너로 이동해야 한다.
  • 창 크기가 변경되면 슬라이드 크기가 자동 조정되어야 한다.

구현 과정

1. 무한 루프를 위한 슬라이드 배열 확장

const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];

2. 슬라이드 크기 동적 업데이트

  const [currentSlideIndex, setCurrentSlideIndex] = useState(1); 

const updateSlideWidth = useCallback(() => {
  if (slideRef.current) {
    setSlideWidth(slideRef.current.offsetWidth);
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
  }
}, [currentSlideIndex]);

currentSlideIndex는 현재 슬라이드 인덱스를 저장하고, updateSlideWidth 함수는 currentSlideIndex가 변경될 때마다 실행됩니다.

현재 슬라이드가 있다면 현재 슬라이드 width 값을 가져와 슬라이드 너비를 업데이트합니다.

3. 슬라이드 이동 로직

const [isAnimating, setIsAnimating] = useState(true);
const [isTransitioning, setIsTransitioning] = useState(false);

const moveToNextSlide = useCallback(() => {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) => prev + 1);
}, [isTransitioning]);

const moveToPrevSlide = useCallback(() => {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) => prev - 1);
}, [isTransitioning]);

isAnimating는 애니메이션 상태를 관리합니다.
isTransitioning는 슬라이드가 애니메이션 중인지 여부를 나타내는 상태입니다.

isTransitioning 상태가 true라면 애니메이션 중이므로 중복 호출을 방지합니다. 그렇지 않다면 슬라이드 전환이 시작되었기 때문에 상태를 true로 바꿉니다. 슬라이드 인덱스 또한 업데이트 해 줍니다.

4. 무한 슬라이드 구현

useEffect(() => {
  if (!slideRef.current) return;

  if (isAnimating) {
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
  } else {
    if (currentSlideIndex === 1) {
      slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
    } else if (currentSlideIndex === banners.length) {
      slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
    }
  }

  const transitionEndHandler = () => {
    if (currentSlideIndex === banners.length + 1) {
      setIsAnimating(false);
      setCurrentSlideIndex(1);
    } else if (currentSlideIndex === 0) {
      setIsAnimating(false);
      setCurrentSlideIndex(banners.length);
    }
    setIsTransitioning(false);
  };

  slideRef.current.addEventListener('transitionend', transitionEndHandler);
  return () => slideRef.current?.removeEventListener('transitionend', transitionEndHandler);
}, [currentSlideIndex, slideWidth, banners.length, isAnimating]);

📌 1. 기본적인 슬라이드 이동

currentSlideIndex가 변경되면 useEffect가 실행됩니다.
isAnimating이 true이면 translateX(-currentSlideIndex * slideWidth)를 적용하여 슬라이드를 이동시킵니다.
transitionend 이벤트가 발생하면 transitionEndHandler를 실행합니다.

📌 2. 무한 루프 효과

마지막 배너(banners.length)에서 다음 슬라이드로 이동하면?
currentSlideIndex === banners.length + 1 → 첫 번째 배너(1번)로 이동합니다.

첫 번째 배너(1번)에서 이전 슬라이드로 이동하면?
currentSlideIndex === 0 → 마지막 배너(banners.length)로 이동하여 이를 통해 슬라이드가 처음과 끝을 무한히 반복하는 것처럼 보이게 만듭니다.

5. 자동 슬라이드 기능

useEffect(() => {
  const interval = setInterval(() => {
    moveToNextSlide();
  }, 3000);

  return () => clearInterval(interval);
}, [moveToNextSlide]);

3초마다 자동으로 배너가 이동하도록 설정합니다. 컴포넌트가 언마운트될 때 clearInterval을 호출하여 메모리 누수를 방지합니다.

전체 코드

import React, { useRef, useState, useEffect, useCallback } from 'react';
import * as Styled from './Banner.styles';
import { SlideButton } from '@/utils/banners';

export interface BannerProps {
  backgroundImage?: string;
}

interface BannerComponentProps {
  banners: BannerProps[];
}

const Banner = ({ banners }: BannerComponentProps) => {
  const slideRef = useRef<HTMLDivElement>(null);
  const [currentSlideIndex, setCurrentSlideIndex] = useState(1);
  const [slideWidth, setSlideWidth] = useState(0);
  const [isAnimating, setIsAnimating] = useState(true);
  const [isTransitioning, setIsTransitioning] = useState(false);

  const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];

  const updateSlideWidth = useCallback(() => {
    if (slideRef.current) {
      setSlideWidth(slideRef.current.offsetWidth);
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
    }
  }, [currentSlideIndex]);

  useEffect(() => {
    updateSlideWidth();
    window.addEventListener('resize', updateSlideWidth);

    return () => {
      window.removeEventListener('resize', updateSlideWidth);
    };
  }, [updateSlideWidth]);

  useEffect(() => {
    if (!slideRef.current) return;

    if (isAnimating) {
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
    } else {
      // 애니메이션 없이 즉시 이동
      if (currentSlideIndex === 1) {
        slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
      } else if (currentSlideIndex === banners.length) {
        slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
      }
    }

    const transitionEndHandler = () => {
      if (currentSlideIndex === banners.length + 1) {
        setIsAnimating(false);
        setCurrentSlideIndex(1);
      } else if (currentSlideIndex === 0) {
        setIsAnimating(false);
        setCurrentSlideIndex(banners.length);
      }
      setIsTransitioning(false);
    };

    slideRef.current.addEventListener('transitionend', transitionEndHandler);
    return () => {
      slideRef.current?.removeEventListener(
        'transitionend',
        transitionEndHandler,
      );
    };
  }, [currentSlideIndex, slideWidth, banners.length, isAnimating]);

  const moveToNextSlide = useCallback(() => {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) => prev + 1);
  }, [isTransitioning]);

  const moveToPrevSlide = useCallback(() => {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) => prev - 1);
  }, [isTransitioning]);

  useEffect(() => {
    const interval = setInterval(() => {
      moveToNextSlide();
    }, 3000);

    return () => clearInterval(interval);
  }, [moveToNextSlide]);

  return (
    <Styled.BannerContainer>
      <Styled.BannerWrapper>
        <Styled.ButtonContainer>
          <Styled.SlideButton onClick={moveToPrevSlide}>
            <img src={SlideButton[0]} alt='Previous Slide' />
          </Styled.SlideButton>
          <Styled.SlideButton onClick={moveToNextSlide}>
            <img src={SlideButton[1]} alt='Next Slide' />
          </Styled.SlideButton>
        </Styled.ButtonContainer>
        <Styled.SlideWrapper ref={slideRef} isAnimating={isAnimating}>
          {extendedBanners.map((banner, index) => (
            <Styled.BannerItem key={index}>
              <img
                src={banner.backgroundImage}
                alt={`banner-${index}`}
                style={{
                  width: '100%',
                  height: '100%',
                  objectFit: 'cover',
                }}
              />
            </Styled.BannerItem>
          ))}
        </Styled.SlideWrapper>
      </Styled.BannerWrapper>
    </Styled.BannerContainer>
  );
};

export default Banner;

스타일링

import styled from 'styled-components';
import { BannerProps } from './Banner';

export const BannerContainer = styled.div`
  padding: 0 40px;
  max-width: 1180px;
  margin: 0 auto;
  width: 100%;

  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 90px;
  position: relative;

  @media (max-width: 500px) {
    margin-top: 42px;
    padding: 0;
  }
`;

export const BannerWrapper = styled.div<BannerProps>`
  position: relative;
  width: 100%;
  max-width: 1180px;
  height: auto;
  aspect-ratio: 1180 / 316;
  border-radius: 26px;
  overflow: hidden;
  background-color: transparent;
  ${({ backgroundImage }) =>
    backgroundImage &&
    `
    background-image: url(${backgroundImage});
    background-size: cover;
    background-position: center;
    `}

  @media (max-width: 500px) {
    width: 100vw;
    border-radius: 0;
  }
`;

export const SlideWrapper = styled.div<{ isAnimating: boolean }>`
  display: flex;
  width: 100%;
  height: 100%;
  ${({ isAnimating }) =>
    isAnimating
      ? 'transition: transform 0.5s ease-in-out;'
      : 'transition: none;'}
`;

export const BannerItem = styled.div`
  flex: none;
  width: 100%;
  height: 100%;
  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
`;
export const ButtonContainer = styled.div`
  position: absolute;
  width: 100%;
  top: 50%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transform: translateY(-50%);
  z-index: 1;
`;

export const SlideButton = styled.button`
  width: 60px;
  height: auto;
  padding: 10px 20px;
  border: none;
  background-color: transparent;
  cursor: pointer;

  img {
    width: 100%;
    height: auto;
    object-fit: cover;
  }

  @media (max-width: 698px) {
    width: 35px;
    padding: 6px 12px;
  }


  @media (max-width: 375px) {
    width: 30px;
    padding: 4px 8px;
  }
`;

구현 영상

profile
FrontEnd Developer

2개의 댓글

comment-user-thumbnail
2025년 3월 1일

시연 영상을 GIF같은 걸로 올리면 이해하기 쉽고 좋을 것 같아요! ✌️

1개의 답글

관련 채용 정보