평범한 이미지 갤러리는 흔한 것 같아, 3D 카드 캐러셀 갤러리를 만들어 보았어요.
레퍼런스러 삼고 있는 사이트에서 참고해 바이브코딩으로 구현해 보았어요.
작업을 하며 가장 신경 쓴 부분이 자연스럽게 넘기는 부분이었어요. 카드가 회전할 때 너무 빠르면 어지럽고, 너무 느리면 답답하니까요.
느낌상 1.5초가 가장 적절했던 거 같아요. 카드가 옆으로 살짝 돌아가면서 뒤로 넘어가는 모션을 넣었는데, 생각보다 괜찮았어요.
처음엔 단순 카드를 위아래로만 움직이려고 했는데, 밋밋한 느낌이 들어서 3D 회전을 추가했죠.
rotationY와 rotationX값을 조금씩 조절하니 훨씬 자연스러웠어요.
특히, 카드가 옆으로 빠져나갈 때 살짝 기울어지는 효과를 넣으니 카드를 손으로 넘기는 듯한 느낌이 들었어요.
포트폴리오 사이트를 만들고 있으시다면 유용하게 사용하실 수 있을 거 같아요.
작품 이미지를 넣으면 자동으로 순환하면서 보여주니까요.
쇼핑몰에서 신상품을 소개할 때도 좋을 거 같고, 회사 소개 페이지 등으로 활용해도 될 거 같아요.
코드를 조금만 수정하면 이미지 개수, 속도, 크기 등을 쉽게 바꿀 수 있었어요. 이미지 URL 배열에 원하는 이미지 주소만 넣으면 바로 작동하니까 그리 어렵지 않으실거예요.
<소스코드>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Card Carousel</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: radial-gradient(ellipse at center, #1a1a1a 0%, #000000 100%);
height: 100vh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
position: relative;
}
/* 배경 파티클 효과 */
.particles {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
}
.particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 20s infinite linear;
}
@keyframes float {
from {
transform: translateY(100vh) translateX(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
transform: translateY(-100vh) translateX(100px);
opacity: 0;
}
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.card-list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: visible;
width: 100%;
height: 100%;
list-style: none;
position: relative;
perspective: 1000px;
}
.card-list-item {
position: absolute;
animation-fill-mode: forwards;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.3s ease, filter 0.3s ease;
}
.card {
--size: 450px;
display: flex;
width: var(--size);
height: var(--size);
border-radius: 20px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.9),
0 0 100px rgba(255, 255, 255, 0.05),
inset 0 0 30px rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
background-color: #0a0a0a;
position: relative;
transform-style: preserve-3d;
}
/* 카드 광택 효과 */
.card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.03) 50%,
transparent 70%
);
transform: rotate(45deg);
transition: transform 0.6s;
pointer-events: none;
}
.card-list-item:hover .card::before {
transform: rotate(45deg) translateX(100%);
}
.card-list-item:hover {
transform: translateZ(50px) scale(1.02);
filter: brightness(1.1);
z-index: 1000 !important;
}
.card-list-item:hover .card {
box-shadow:
0 30px 80px rgba(0, 0, 0, 1),
0 0 120px rgba(255, 255, 255, 0.1),
0 0 40px rgba(255, 255, 255, 0.05);
}
/* 반사 효과 */
.card::after {
content: '';
position: absolute;
bottom: -101%;
left: 0;
width: 100%;
height: 100%;
background: inherit;
background-size: cover;
transform: scaleY(-1);
opacity: 0.1;
filter: blur(10px);
mask-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, transparent 30%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, transparent 30%);
}
@media (max-width: 768px) {
.card {
--size: 320px;
}
}
@media (max-width: 480px) {
.card {
--size: 260px;
}
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ffffff;
font-size: 28px;
letter-spacing: 4px;
font-weight: 100;
z-index: 10;
text-transform: uppercase;
opacity: 0.8;
}
.loading::after {
content: '';
display: inline-block;
animation: dots 1.5s infinite;
}
@keyframes dots {
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
}
/* 카드 번호 표시 */
.card-number {
position: absolute;
top: 20px;
right: 20px;
color: rgba(255, 255, 255, 0.1);
font-size: 48px;
font-weight: bold;
z-index: 1;
pointer-events: none;
}
</style>
</head>
<body>
<div class="particles" id="particles"></div>
<div class="container">
<div class="loading">Loading</div>
<ul class="card-list"></ul>
</div>
<script>
// 파티클 생성
function createParticles() {
const particlesContainer = document.getElementById('particles');
for (let i = 0; i < 50; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 20 + 's';
particle.style.animationDuration = (15 + Math.random() * 10) + 's';
particlesContainer.appendChild(particle);
}
}
createParticles();
const allImages = [
"https://cdn.midjourney.com/5054872e-6418-4726-9c62-1e7f44471af8/0_0.png",
"https://cdn.midjourney.com/5054872e-6418-4726-9c62-1e7f44471af8/0_1.png",
"https://cdn.midjourney.com/5054872e-6418-4726-9c62-1e7f44471af8/0_2.png",
"https://cdn.midjourney.com/5054872e-6418-4726-9c62-1e7f44471af8/0_3.png",
"https://cdn.midjourney.com/32a0c453-2a1e-4fd1-90c2-833e06c119c9/0_0.png",
"https://cdn.midjourney.com/32a0c453-2a1e-4fd1-90c2-833e06c119c9/0_1.png",
"https://cdn.midjourney.com/32a0c453-2a1e-4fd1-90c2-833e06c119c9/0_2.png",
"https://cdn.midjourney.com/32a0c453-2a1e-4fd1-90c2-833e06c119c9/0_3.png",
"https://cdn.midjourney.com/30191671-2686-47a0-abcb-ebb3bd640818/0_0.png",
"https://cdn.midjourney.com/30191671-2686-47a0-abcb-ebb3bd640818/0_1.png",
"https://cdn.midjourney.com/30191671-2686-47a0-abcb-ebb3bd640818/0_2.png",
"https://cdn.midjourney.com/30191671-2686-47a0-abcb-ebb3bd640818/0_3.png",
"https://cdn.midjourney.com/def82064-04dd-40b6-8506-4a2974de2706/0_0.png",
"https://cdn.midjourney.com/def82064-04dd-40b6-8506-4a2974de2706/0_1.png",
"https://cdn.midjourney.com/def82064-04dd-40b6-8506-4a2974de2706/0_2.png",
"https://cdn.midjourney.com/def82064-04dd-40b6-8506-4a2974de2706/0_3.png",
"https://cdn.midjourney.com/245b7903-de0c-4ce3-8369-88a44e31efe8/0_0.png",
"https://cdn.midjourney.com/245b7903-de0c-4ce3-8369-88a44e31efe8/0_1.png",
"https://cdn.midjourney.com/245b7903-de0c-4ce3-8369-88a44e31efe8/0_2.png",
"https://cdn.midjourney.com/245b7903-de0c-4ce3-8369-88a44e31efe8/0_3.png"
].sort(() => Math.random() - 0.5);
const numImages = allImages.length;
const sPerShuffle = 1.5;
const marginToSet = 10;
gsap.ticker.lagSmoothing(false);
window.onload = function () {
setTimeout(() => {
gsap.to('.loading', {
opacity: 0,
duration: 0.5,
onComplete: () => {
document.querySelector('.loading').style.display = 'none';
}
});
createElements();
}, 1000);
function createElements() {
let tl = gsap.timeline({
repeat: -1,
onRepeat: () => {
resetElements();
}
});
const cardList = getUpdatedCardList();
var counter = 0;
allImages.forEach((imageUrl, index) => {
const newLi = document.createElement("li");
newLi.classList.add("card-list-item");
newLi.id = "card_" + counter;
const zIndexCSS = "z-index: " + (numImages * 2 - counter) + ";";
const marginCSS = getMarginCSS(counter);
const transitionCSS = "transition: z-index 0s ease-in-out, margin-bottom 0.2s cubic-bezier(0.4, 0, 0.2, 1);";
newLi.style.cssText = zIndexCSS + marginCSS + transitionCSS;
const newDiv = document.createElement("div");
newDiv.classList.add("card");
const backgroundImgCSS = "background-image: url(" + imageUrl + ");";
const bgCSS = "background-repeat: no-repeat; background-size: cover; background-position: center;";
newDiv.style.cssText = backgroundImgCSS + bgCSS;
// 카드 번호 추가
const cardNumber = document.createElement("div");
cardNumber.className = "card-number";
cardNumber.textContent = (index + 1).toString().padStart(2, '0');
newDiv.appendChild(cardNumber);
newLi.appendChild(newDiv);
cardList.appendChild(newLi);
const cardId = "#" + newLi.id;
// 3D 회전 효과와 함께 애니메이션
tl
.from(cardId, {
xPercent: 0,
yPercent: 0,
rotationY: 0,
rotationX: 0,
scale: 1,
opacity: 1
})
.to(cardId, {
xPercent: 120,
yPercent: -30,
rotationY: -25,
rotationX: 5,
scale: 0.85,
duration: sPerShuffle / 2,
ease: "power3.inOut",
onComplete: () => {
const actualCounter = newLi.id.split("_")[1];
const updatedLi = document.getElementById(newLi.id);
updatedLi.style.zIndex = numImages - actualCounter - 1;
}
})
.to(cardId, {
xPercent: 0,
yPercent: -35,
rotationY: 0,
rotationX: 0,
scale: 1,
duration: sPerShuffle / 2,
ease: "power3.out"
})
.to(cardId, {
duration: 0.01,
yPercent: 0,
onComplete: () => {
modifyMargins();
}
});
counter++;
});
// 초기 카드 애니메이션
gsap.from(".card-list-item", {
scale: 0,
opacity: 0,
rotationY: 180,
duration: 0.8,
stagger: 0.1,
ease: "back.out(1.7)"
});
}
function modifyMargins() {
var counter = 0;
getUpdatedCardList().childNodes.forEach((card) => {
const actualMargin = parseInt(card.style.marginBottom.split("px")[0]);
var marginCSS = actualMargin - marginToSet;
if (actualMargin === 0) {
marginCSS = numImages * marginToSet;
}
card.style.marginBottom = marginCSS + "px";
counter++;
});
}
function resetElements() {
var counter = 0;
getUpdatedCardList().childNodes.forEach((card) => {
const zIndexCSS = "z-index: " + (numImages * 2 - counter) + ";";
const marginCSS = getMarginCSS(counter);
const transitionCSS = "transition: z-index 0s ease-in-out, margin-bottom 0.2s cubic-bezier(0.4, 0, 0.2, 1);";
card.style.cssText = zIndexCSS + marginCSS + transitionCSS;
counter++;
});
}
function getMarginCSS(counter) {
return "margin-bottom: " + counter * marginToSet + "px;";
}
function getUpdatedCardList() {
return document.getElementsByClassName("card-list")[0];
}
// 마우스 추적 효과
document.addEventListener('mousemove', (e) => {
const cards = document.querySelectorAll('.card-list-item');
const mouseX = e.clientX / window.innerWidth - 0.5;
const mouseY = e.clientY / window.innerHeight - 0.5;
cards.forEach((card, index) => {
if (!card.style.animationName) {
const depth = index * 0.1;
const moveX = mouseX * depth * 10;
const moveY = mouseY * depth * 10;
gsap.to(card, {
x: moveX,
y: moveY,
duration: 0.5,
ease: "power2.out"
});
}
});
});
};
</script>
</body>
</html>