캐러셀 슬라이드는 가로, 혹은 세로 축으로 회전하며 일련의 이미지를 보여주는 이미지 갤러리의 일종으로 특히 다양한 이미지들을 사용자에게 시각적으로 매력적으로 보여줄 필요가 있을 때 사용됩니다. 일반적으로 온라인 쇼핑몰 사이트에서 제품 이미지를 보여주는 메인 배너에 많이 사용되고, 그 외에도 개인 포트폴리오 또는 갤러리 사이트에서 작품이나 이미지 모음을 전시할 때 사용될 수 있습니다.
외부 라이브러리를 사용하지 않고 캐러셀 슬라이드를 구현합니다. 이 글에서는 바닐라 자바스크립트를 사용하여 무한 캐러셀 슬라이드를 만드는 방법을 설명합니다. 다음은 이 슬라이드에서 구현하고자 하는 목표입니다.
캐러셀 슬라이드를 구현하기 위해서는 먼저 뼈대가 되는 HTML을 작성해야 합니다.
<div class="slider__wrapper" aria-live="polite">
<h1 class="sr-only">Infinite Carousel Slide</h1>
...
</div>
aria-live
속성을 "polite"로 설정합니다.sr-only
를 사용해 화면상에서는 보이지 않도록 감춰줍니다.<div class="slider">
<div class="slide" data-index="1" aria-labelledby="slide1">
<h2 class="slide__title" id="slide1">
Today-I-Learned Carousel Slide1
</h2>
</div>
...
</div>
aria-labelldeby
속성과 슬라이드의 라벨 역할을 하는 제목 태그의 id
속성을 일치시키도록 합니다. 이렇게 하면 슬라이드의 제목을 스크린 리더의 사용자에게 명시할 수 있습니다.<div class="slider-control">
<button id="prevBtn" class="control__button prev" aria-label="Previous slide button"></button>
<button id="nextBtn" class="control__button next" aria-label="Next slide button"></button>
...
</div>
aria-label
속성을 사용하여 이전 및 다음 버튼이 어떤 역할을 하고 있는지 스크린 리더 사용자에게 알려 줄 수 있습니다.<p class="slide-count" aria-describedby="slide-description">
<span class="current-slide"></span>
/
<span class="all-slide"></span>
</p>
<p id="slide-description" class="sr-only">
Use the previous and next buttons to navigate between the slides.
</p>
aria-describedby
속성을 사용해 이전 및 다음 버튼을 사용하여 슬라이드 사용법에 대해 설명합니다.<div class="slider__wrapper" aria-live="polite">
<h1 class="sr-only">Infinite Carousel Slide</h1>
<div class="slider">
<div class="slide" data-index="1" aria-labelledby="slide1">
<h2 class="slide__title" id="slide1">
Today-I-Learned Carousel Slide1
</h2>
</div>
<div class="slide" data-index="2" aria-labelledby="slide2">
<h2 class="slide__title" id="slide2">
Today-I-Learned Carousel Slide2
</h2>
</div>
<div class="slide" data-index="3" aria-labelledby="slide3">
<h2 class="slide__title" id="slide3">
Today-I-Learned Carousel Slide3
</h2>
</div>
</div>
<div class="slider-control">
<button
id="prevBtn"
class="control__button prev"
aria-label="Previous slide button"
></button>
<button
id="nextBtn"
class="control__button next"
aria-label="Next slide button"
></button>
<p class="slide-count" aria-describedby="slide-description">
<span class="current-slide"></span>
/
<span class="all-slide"></span>
</p>
<p id="slide-description" class="sr-only">
Use the previous and next buttons to navigate between the slides.
</p>
</div>
</div>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
border: 0;
clip: rect(0, 0, 0, 0);
overflow: hidden;
}
.slider__wrapper {
position: relative;
width: 630px;
height: 760px;
margin: 120px auto;
overflow: hidden;
}
.slider {
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
height: 100%;
}
.slide {
width: 630px;
height: 760px;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.slider-control {
position: absolute;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
bottom: 115px;
left: 120px;
}
.slider-control .control__button {
border: none;
border-radius: 0;
width: 40px;
height: 40px;
color: #fff;
cursor: pointer;
}
.slider-control .slide-count {
margin-left: 10px;
color: #fff;
}
(function () {
"use strict";
// querySelectorAll 메서드를 사용하여 단일 / 복수 요소를 선택하는 유틸 함수
const get = (target) => {
const els = document.querySelectorAll(target);
return els.length > 1 ? els : els[0];
};
const $slideContainer = get(".slider__wrapper");
const $slider = get(".slider");
const $slideIndicator = get(".slide-count");
const $totalSlides = get(".all-slide");
const $currentSlide = get(".current-slide");
const $prevBtn = get(".control__button.prev");
const $nextBtn = get(".control__button.next");
const $slide = get(".slide");
const slideWidth = $slide[0].clientWidth;
const slideAmount = $slide.length;
const sliderWidth = slideWidth * slideAmount;
const slideSpeed = 1000;
let currentIndex = 1;
let moveOffset = 0;
let interval;
// 현재 슬라이드 인덱스를 기준으로 슬라이더 구성요소에 대한 접근성 속성을 설정
const setAccessibility = () => {
for (let i = 0; i < $slider.children.length; i++) {
if (i === currentIndex) {
$slider.children[i].setAttribute("aria-hidden", false);
} else {
$slider.children[i].setAttribute("aria-hidden", true);
}
}
// slide-count에 aria-label 속성을 추가하고 현재 슬라이드의 인덱스를 추가한다.
setTimeout(() => {
$slideIndicator.setAttribute("aria-label", `slide ${$currentSlide.textContent} of ${slideAmount}`);
}, 100);
};
// setInterval을 사용, 3초마다 handleSwipe를 트리거하는 자동 재생 함수
const slideAutoPlay = () => {
interval = setInterval(() => {
handleSwipe(1);
if (currentIndex === $slider.children.length - 1) {
setTimeout(() => {
$slider.style.transition = "none";
currentIndex = 1;
moveOffset = (100 / slideAmount) * currentIndex;
$slider.style.transform = `translateX(-${moveOffset}%)`;
}, slideSpeed);
}
}, 3000);
};
/**
* direction
* next : 1, prev : -1
*
* handleSwipe는 이전, 다음 버튼을 클릭하거나 자동으로 슬라이드가 넘어갈 때 실행되는 함수로
* 같은 함수에서 이전, 다음 버튼을 클릭했을 때의 이벤트를 구분하기 위해
* direction을 사용한다.
*/
const handleSwipe = (direction) => {
currentIndex = currentIndex + direction;
if (currentIndex >= slideAmount + 2) {
currentIndex = 4;
} else if (currentIndex <= 0) {
currentIndex = 0;
}
moveOffset = (100 / slideAmount) * currentIndex;
if (currentIndex <= 0) {
$currentSlide.textContent =
$slider.children[$slider.children.length - 2].dataset.index;
} else if (currentIndex >= $slider.children.length - 1) {
$currentSlide.textContent = $slider.children[1].dataset.index;
} else {
$currentSlide.textContent = $slider.children[currentIndex].dataset.index;
}
$slider.style.transform = `translateX(-${moveOffset}%)`;
$slider.style.transition = `all ${slideSpeed}ms ease`;
setAccessibility();
};
// 이전, 다음 버튼 클릭 이벤트 핸들러
// handleSwipe 함수를 호출하면서 슬라이드의 진행 방향을 인자로 넘겨주는 함수
const handleMoveBtn = (event) => {
event.preventDefault();
const $target = event.currentTarget;
if ($target.id === "nextBtn") {
handleSwipe(1);
if (currentIndex === $slider.children.length - 1) {
setTimeout(() => {
$slider.style.transition = "none";
currentIndex = 1;
moveOffset = (100 / slideAmount) * currentIndex;
$slider.style.transform = `translateX(-${moveOffset}%)`;
}, slideSpeed);
}
} else {
handleSwipe(-1);
if (currentIndex === 0) {
setTimeout(() => {
$slider.style.transition = "none";
currentIndex = $slider.children.length - 2;
moveOffset = (100 / slideAmount) * currentIndex;
$slider.style.transform = `translateX(-${moveOffset}%)`;
}, slideSpeed);
}
}
};
// 슬라이드의 초기 레이아웃 SetUp
const setSlideLayout = () => {
// 무한 슬라이드를 위해 첫번째 슬라이드와 마지막 슬라이드를 복제
const $firstSlideClone = $slider.firstElementChild.cloneNode(true);
const $lastSlideClone = $slider.lastElementChild.cloneNode(true);
$slider.insertBefore($lastSlideClone, $slider.firstElementChild);
$slider.appendChild($firstSlideClone);
// 슬라이드의 너비를 설정하고, 1번째 슬라이드로 위치 초기화 (translateX)
$slider.style.width = `${sliderWidth}px`;
$slider.style.transform = `translateX(-${slideWidth}px)`;
setAccessibility();
};
const handleMouseEnter = (event) => {
event.preventDefault();
event.stopPropagation();
clearInterval(interval);
};
const handleMouseLeave = (e) => {
slideAutoPlay();
};
// 사용자가 이전, 다음 버튼을 클릭할 때, throttle 함수를 사용하여
// 짧은 시간 내 과도한 이벤트가 일어나지 않도록 방지
const throttle = (fn, delay) => {
let lastCall = 0;
return function (...args) {
const now = new Date().getTime();
if (now - lastCall < delay) {
return;
}
lastCall = now;
fn(...args);
};
};
const handleMoveBtnThrottled = throttle(handleMoveBtn, 1000);
const init = () => {
setSlideLayout();
slideAutoPlay();
$totalSlides.textContent = slideAmount;
$currentSlide.textContent = currentIndex;
$prevBtn.addEventListener("click", handleMoveBtnThrottled);
$nextBtn.addEventListener("click", handleMoveBtnThrottled);
$slideContainer.addEventListener("mouseenter", handleMouseEnter);
$slideContainer.addEventListener("mouseleave", handleMouseLeave);
};
window.addEventListener("DOMContentLoaded", init);
})();
처음부터 캐러셀 슬라이드에 구현하고 싶은 기능이 명확하게 있었기 때문에 다른 슬라이드는 고려하지 않고 작업을 시작했습니다. 아무것도 구현되지 않은 상태에서 기능을 한 번에 올리다보니 여러 문제를 맞닥뜨리게 되었는데요.
한 번에 고려해야 할 것이 너무 많다보니 예상대로 동작하지 않을 때가 많아 예상 소요 시간보다 더 오랜 시간을 구현과 에러 추적에 써야 했고,
기능 단위로 구현하지 않았기 때문에 내가 지금 어떤 기능을 구현했고, 다음에 어떤 기능을 구현해야 하는지. 전체적으로 봤을 때 몇 퍼센트를 구현했는지 판단할 수 없었다는 것입니다.
위와 같은 문제로 구현 완료 전까지 슬라이드의 기능을 아무것도 사용할 수 없었고, MVP(최소 기능 제품, Minimum Viable Product) 개발 방법론에 대해 좀 더 찾아보게되는 계기가 되었습니다.
Reference
① 무한 슬라이드 만들기(infinite carousel) +애니메이션 https://ye-yo.github.io/react/2022/01/21/infinite-carousel.html
② Infinite Carousel - vanilla Javascript https://codepen.io/Marouen/pen/OxyEEY
③ Minimum Viable Products - What does MVP mean for your startup?
https://goldenowl.asia/blog/minimum-viable-products-what-does-mvp-mean-for-your-startup