소프티어 부트캠프를 진행하는 동안 캐러셀을 구현해 보았는데 그 내용을 정리하고자 한다!


.slider {
position: relative;
height: 500px;
transform-style: preserve-3d;
perspective: 400px; /* 깊이감을 주기 위해 설정 */
}
transform-style를 preserve-3d로 주어 3D 공간에 위치할 수 있도록 했다. 또한 perspective을 주어 translateZ를 주어 원근감이 느껴질 수 있도록 했다.
.slider-item {
cursor: pointer;
width: 1200px;
height: 500px;
position: absolute;
transition:
transform 0.35s ease-in-out, /* 위치 이동 애니메이션 */
z-index 0s 0.2s; /* z-index 애니메이션 지연 설정 */
top: 50%;
left: 50%;
}
cursor: pointer로 주어 사진에 클릭 이벤트가 있음을 암시했다.
position: absolute; top: 50%; left: 50%를 주었다.
실제 이렇게 주면 정가운데로 위치하지 않아 translate(-50%, -50%)를 추가적으로 주어야 한다. translate(-50%, -50%)는 자신의 크기의 절반만큼 이동한다.
translate는 transform으로 선언하기에 각각의 요소마다 넣어 주었다.
position: absolute; top: 50%; left: 50% 일 때의 배치

translate(-50%, -50%) 추가

transition의 transform을 활용하여 GPU 가속 적용을 했다.
.s1 {
transform: translate(-50%, -50%) translateX(0) translateZ(0); /* 중앙에 위치 */
z-index: 4;
}
.s2 {
transform: translate(-50%, -50%) translateX(330px) translateZ(-100px); /* 오른쪽으로 이동, 뒤로 약간 물러남 */
z-index: 3;
}
.s3 {
transform: translate(-50%, -50%) translateX(650px) translateZ(-200px); /* 오른쪽으로 더 멀리 이동, 더 뒤로 물러남 */
z-index: 2;
}
.s4 {
transform: translate(-50%, -50%) translateX(-650px) translateZ(-200px); /* 왼쪽으로 멀리 이동, 뒤로 물러남 */
z-index: 2;
}
.s5 {
transform: translate(-50%, -50%) translateX(-330px) translateZ(-100px); /* 왼쪽으로 이동, 뒤로 약간 물러남 */
z-index: 3;
}
.indicator-item {
cursor: pointer;
background-color: #fff;
border-radius: 10px;
height: 20px;
width: 20px;
border: 1px solid lightgrey;
}
.indicator-item.active {
background-color: lightgrey;
}
cursor: pointer를 주어 클릭 이벤트가 있음을 암시
.active를 선언하여 활성화된 indicator는 배경색을 회색으로 주었음
상태를 활용하여 가운데에 어떤 인덱스의 사진이 올 것인지를 설정해주었다. 가운데 올 사진이 바뀌게 되면 재렌더링을 진행해야 하므로 상태로 설정했다.
const [currentIndex, setCurrentIndex] = useState(0);
화살표를 연속적으로 클릭하게 되면 사진이 중앙으로 모이게 되는데 이는 css에서 우선적으로 translate(-50%, -50%)를 사용하여 가운데로 옮긴 후에 작업을 수행하기 때문에 그렇다.
이를 방지하기 위해 isClickDisabled를 useRef로 선언해 주어 해당 변수가 true이면 클릭 이벤트가 발생하지 않도록 하였다.
let을 사용할 때에는 리렌더링이 발생할 때마다 값이 초기화되기에 적합하지 않았다. 또한 변수 선언을 컴포넌트 밖에서 하는 경우도 가능한데 이 경우 링크의 예시에서 useRef와의 차이를 확인할 수 있다!
const isClickDisabled = useRef(false);
총 사진의 개수를 나타내는 totalItems 변수를 선언하여 가독성을 높였다.
const totalItems = 5;
const getSliderClasses = index => {
const positions = ['s1', 's2', 's3', 's4', 's5'];
return positions[(index - currentIndex + totalItems) % totalItems]; //s1,s2,s3,s4,s5는 위치를 지정
};
// newCarCarouselData를 순회하며 사진을 적용
<div className="slider">
{newCarCarouselData.map((item, idx) => (
<div
key={item.id}
className={`slider-item ${getSliderClasses(idx)}`}
>
<img
src={item.imageSrc}
alt={`Car Image ${item.id}`}
className="object-cover w-full h-full select-none"
onClick={() => moveToIndex(idx)}
loading="lazy"
draggable="false"
/>
</div>
))}
</div>
css의 class 명으로 사진의 위치를 설정해주기 때문에 s1, s2, s3, s4, s5를 사진을 담을 div 태그의 클래스로 넣어 줄 필요가 있었다.
예를 들어 currentIndex가 0이라면 아래와 같이 인덱스들이 배치되어야 한다.

const handleCarousel = direction => {
if (isClickDisabled.current) return; //화살표 추가 클릭 방지
isClickDisabled.current = true;
setCurrentIndex(
prevIndex => (prevIndex + (totalItems + direction)) % totalItems,
);
setTimeout(() => {
isClickDisabled.current = false;
}, 350);
};
//위의 함수를 왼쪽 화살표, 오른쪽 화살표에 onClick 함수를 적용한 것
//tailwindcss를 적용하여 cursor:pointer가
//className="hover:cursor-pointer"로 적용되었다.
<div className="flex justify-center mt-600 mb-3000 gap-600">
<img
src={arrowLeftCircle}
alt="Previous Slide"
onClick={() => handleCarousel(-1)}
className="hover:cursor-pointer"
/>
<img
src={arrowRightCircle}
alt="Next Slide"
onClick={() => handleCarousel(1)}
className="hover:cursor-pointer"
/>
</div>
handleCarousel 함수는 화살표를 클릭했을 때 회전을 주는 함수이다. 어느 방향으로 돌 것인가에 대한 정보(direction)을 받아 해당 방향으로 회전한다.
위의 currentIndex가 0일 때의 예시를 이어서 설명해보자!
위의 상황에서 왼쪽 화살표를 클릭한다면 왼쪽 사진이 가운데로 와야 하고 이는 아래와 같다.

반대로 오른쪽 화살표를 클릭하면 오른쪽 사진이 가운데로 와야 하고 이는 아래와 같다.

이렇게 currentIndex를 변경하기 위해 SetCurrentIndex(prevIndex => (prevIndex + (totalItems + direction)) % totalItems)를 호출해주었다.
const moveToIndex = async targetIndex => {
if (isClickDisabled.current) return; //화살표 버튼 클릭 시 추가 클릭 방지
let diff = targetIndex - currentIndex;
if (diff >= 3) diff = -5 + diff;
else if (diff <= -3) diff = 5 + diff;
const direction = Math.sign(diff);
isClickDisabled.current = true;
for (let i = 0; i < Math.abs(diff); i++) {
setCurrentIndex(
prevIndex => (prevIndex + (totalItems + direction)) % totalItems,
);
await new Promise(resolve => setTimeout(resolve, 350));
}
isClickDisabled.current = false;
};
사진이나 Indicator를 클릭하여 이동할 때 호출되는 함수이다.
Math.abs(diff)는 총 몇 번을 이동해야 하는가에 대한 값이다.
Math.sign(diff)는 회전 방향이다. 만약 Promise가 없으면 클릭한 사진이 바로 중앙으로 온다. 하지만 한 칸씩 이동하도록 유도하기 위해 Promise를 활용했다. 0.35초마다 한 장씩 넘어가며 클릭한 사진이 가운데로 오도록 유도했다.
/* 슬라이더가 가운데 정렬되도록 설정 */
.slider-container {
display: flex;
justify-content: center;
align-items: center;
}
/* 슬라이더 자체의 크기와 3D 변환 스타일을 지정하여 3D 느낌의 애니메이션을 가능하게 설정 */
.slider {
position: relative;
height: 500px;
transform-style: preserve-3d;
perspective: 400px; /* 깊이감을 주기 위해 설정 */
}
/* 각 슬라이더 아이템의 스타일 정의: 절대 위치와 애니메이션 적용 */
.slider-item {
cursor: pointer;
width: 1200px;
height: 500px;
position: absolute; /* 각 슬라이드가 겹쳐지도록 설정 */
transition:
transform 0.35s ease-in-out, /* 위치 이동 애니메이션 */
z-index 0s 0.2s; /* z-index 애니메이션 지연 설정 */
top: 50%;
left: 50%; /* 슬라이드 위치 중앙으로 맞춤 */
}
/* 첫 번째 슬라이드(중앙)에 위치하며 가장 앞쪽에 보이도록 설정 */
.s1 {
transform: translate(-50%, -50%) translateX(0) translateZ(0); /* 중앙에 위치 */
z-index: 4; /* 가장 앞에 위치 */
}
/* 두 번째 슬라이드 */
.s2 {
transform: translate(-50%, -50%) translateX(330px) translateZ(-100px); /* 오른쪽으로 이동, 뒤로 약간 물러남 */
z-index: 3; /* s1보다 뒤에 위치 */
}
/* 세 번째 슬라이드 */
.s3 {
transform: translate(-50%, -50%) translateX(650px) translateZ(-200px); /* 오른쪽으로 더 멀리 이동, 더 뒤로 물러남 */
z-index: 2; /* s2보다 더 뒤에 위치 */
}
/* 네 번째 슬라이드 */
.s4 {
transform: translate(-50%, -50%) translateX(-650px) translateZ(-200px); /* 왼쪽으로 멀리 이동, 뒤로 물러남 */
z-index: 2; /* s3와 동일한 z-index */
}
/* 다섯 번째 슬라이드 */
.s5 {
transform: translate(-50%, -50%) translateX(-330px) translateZ(-100px); /* 왼쪽으로 이동, 뒤로 약간 물러남 */
z-index: 3; /* s2와 동일한 z-index */
}
/* 슬라이드 하단의 인디케이터 스타일 */
.indicator-item {
cursor: pointer; /* 클릭 가능하게 설정 */
background-color: #fff;
border-radius: 10px;
height: 20px;
width: 20px;
border: 1px solid lightgrey; /* 경계선 설정 */
}
/* 현재 슬라이드와 일치하는 인디케이터에만 활성화 스타일 적용 */
.indicator-item.active {
background-color: lightgrey;
}
import React, { useState, useRef } from 'react';
import arrowLeftCircle from '@/assets/images/arrowLeftCircle.svg';
import arrowRightCircle from '@/assets/images/arrowRightCircle.svg';
import newCarCarouselData from '@/constants/newCarIntro/newCarCarouselData';
import SlideUpMotion from '@/components/SlideUpMotion/SlideUpMotion';
import '@/styles/newCarCarousel.css';
function NewCarCarousel() {
const [currentIndex, setCurrentIndex] = useState(0); // 현재 슬라이드의 인덱스를 상태로 관리
const isClickDisabled = useRef(false); // 슬라이드 전환 중 추가 클릭 방지를 위한 플래그
const totalItems = 5; // 슬라이드의 총 개수
/* 슬라이드의 인덱스에 따라 위치에 맞는 클래스(s1~s5)를 반환 */
const getSliderClasses = index => {
const positions = ['s1', 's2', 's3', 's4', 's5']; // 슬라이드 포지션 클래스 목록
return positions[(index - currentIndex + totalItems) % totalItems]; // 현재 인덱스 기준으로 위치 설정
};
/* 슬라이드 이동을 처리하는 함수, 방향(좌/우)에 따라 슬라이드 전환 */
const handleCarousel = direction => {
if (isClickDisabled.current) return; // 슬라이드 전환 중이면 클릭 방지
isClickDisabled.current = true; // 클릭 방지 플래그 활성화
setCurrentIndex(
prevIndex => (prevIndex + (totalItems + direction)) % totalItems, // 슬라이드 전환
);
setTimeout(() => {
isClickDisabled.current = false; // 전환 후 클릭 방지 플래그 해제
}, 350); // 슬라이드 전환 시간이 끝난 후 해제
};
/* 특정 인덱스로 슬라이드를 이동시키는 함수 */
const moveToIndex = async targetIndex => {
if (isClickDisabled.current) return; // 슬라이드 전환 중이면 클릭 방지
let diff = targetIndex - currentIndex; // 현재 인덱스와 타겟 인덱스의 차이 계산
if (diff >= 3) diff = -5 + diff; // 순환 슬라이드 처리를 위해 인덱스 차이 보정
else if (diff <= -3) diff = 5 + diff;
const direction = Math.sign(diff); // 이동 방향 설정 (음수: 왼쪽, 양수: 오른쪽)
isClickDisabled.current = true; // 클릭 방지 플래그 활성화
for (let i = 0; i < Math.abs(diff); i++) {
setCurrentIndex(
prevIndex => (prevIndex + (totalItems + direction)) % totalItems, // 한 단계씩 이동
);
await new Promise(resolve => setTimeout(resolve, 350)); // 350ms 대기 후 다음 단계로 이동
}
isClickDisabled.current = false; // 전환이 완료되면 클릭 방지 플래그 해제
};
return (
<div className="new-car-carousel">
<SlideUpMotion delay={1.0}> {/* 슬라이드 업 애니메이션을 위한 컴포넌트 */}
<div className="mx-5000 mb-1100">
<div className="text-body-3-semibold text-primary-blue">
Highlights {/* 제목 영역 */}
</div>
<div className="text-heading-2-bold">
전력을 다해, CASPER Electric {/* 부제목 영역 */}
</div>
</div>
<div className="slider-container">
<div className="slider">
{newCarCarouselData.map((item, idx) => (
<div
key={item.id}
className={`slider-item ${getSliderClasses(idx)}`} /* 슬라이드 위치에 맞는 클래스를 동적으로 추가 */
>
<img
src={item.imageSrc}
alt={`Car Image ${item.id}`} /* 이미지의 alt 속성 설정 */
className="object-cover w-full h-full select-none"
onClick={() => moveToIndex(idx)} /* 클릭 시 해당 슬라이드로 이동 */
loading="lazy" /* 이미지 지연 로딩 */
draggable="false" /* 드래그 방지 */
/>
</div>
))}
</div>
</div>
<div className="flex justify-center mt-600">
{newCarCarouselData.map((item, idx) => (
<div
key={item.id}
className={`indicator-item m-2 ${currentIndex === idx ? 'active' : ''}`} /* 현재 슬라이드와 일치하는 인디케이터에 active 클래스 추가 */
onClick={() => moveToIndex(idx)} /* 클릭 시 해당 슬라이드로 이동 */
></div>
))}
</div>
{/* 슬라이드 화살표 버튼 */}
<div className="flex justify-center mt-600 mb-3000 gap-600">
<img
src={arrowLeftCircle} /* 왼쪽 화살표 이미지 */
alt="Previous Slide"
onClick={() => handleCarousel(-1)} /* 클릭 시 이전 슬라이드로 이동 */
className="hover:cursor-pointer" /* 마우스 오버 시 커서 포인터로 변경 */
/>
<img
src={arrowRightCircle} /* 오른쪽 화살표 이미지 */
alt="Next Slide"
onClick={() => handleCarousel(1)} /* 클릭 시 다음 슬라이드로 이동 */
className="hover:cursor-pointer" /* 마우스 오버 시 커서 포인터로 변경 */
/>
</div>
</SlideUpMotion>
</div>
);
}
export default NewCarCarousel;