저는 현재 학교에서 동아리 지원 관리 플랫폼을 제작 중입니다. 아래는 해당 프로젝트 깃허브 링크입니다.
이번에 메인페이지에 들어갈 캐러셀을 제작하게 되었습니다.
Typescript와 styled-component를 사용하였고 반응형과 무한 캐러셀 동작에 집중하였습니다.
const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];
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 값을 가져와 슬라이드 너비를 업데이트합니다.
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로 바꿉니다. 슬라이드 인덱스 또한 업데이트 해 줍니다.
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)로 이동하여 이를 통해 슬라이드가 처음과 끝을 무한히 반복하는 것처럼 보이게 만듭니다.
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;
}
`;
시연 영상을 GIF같은 걸로 올리면 이해하기 쉽고 좋을 것 같아요! ✌️