| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 해피씨지아이
- php
- 이미지
- CSS
- #해피CGI
- CGIMALL
- 사이트제작
- #이미지
- 웹솔루션
- #뉴스
- 솔루션
- 웹사이트제작
- javascript
- #happycgi
- jquery
- #솔루션
- 홈페이지
- 해피CGI
- #jQuery
- happycgi
- #업종별
- #CSS
- 홈페이지제작
- #홈페이지제작
- #웹솔루션
- #쇼핑몰
- #cgimall
- 게시판
- #image
- #홈페이지
- Today
- Total
웹솔루션개발 26년 노하우! 해피CGI의 모든것
[해피CGI][cgimall] 입체적인 이미지 카드 캐러셀 본문
이미지 카드를 겹쳐 배치한 뒤 선택한 카드가 중앙에 표시되도록 구성한 캐러셀입니다.
이전·다음 버튼이나 하단 인디케이터를 이용해 원하는 이미지를 선택할 수 있습니다.
카드가 이동하면서 회전과 크기 변화가 함께 적용되어 자연스럽고 입체적인 전환 효과를 보여줍니다.

HTML 구조
<div id="app"></div>
CSS 소스
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap');
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
background-color: #ececec;
font-family: "Bricolage Grotesque", sans-serif;
display: grid;
place-content: center;
overflow: hidden;
}
JS 소스
import React, { useState, useEffect } from "https://esm.sh/react@19"
import { createRoot } from "https://esm.sh/react-dom@19/client"
import { motion } from "https://esm.sh/motion/react"
import { ChevronLeft, ChevronRight } from "https://esm.sh/lucide-react"
const ASSETS = [
{
title: 'A blurry photo of a car\'s tail light photo',
},
{
title: 'Shallow focus photo of person holding video camera photo',
},
{
title: 'Silhouette of man sitting on chair photo',
},
{
title: 'Black and gray camera tripod photo',
},
{
title: 'A blurry photo of a tree with a blue sky in the background photo',
},
{
title: 'Woman in black and white floral shirt photo',
},
{
title: 'Person holding silver round ornament photo',
},
{
title: 'Person holding clear glass ball photo',
},
{
title: 'Woman in red and black dress photo'
},
{
title: 'Man in black jacket holding camera photo',
},
]
const WIDTH = 300
const IDLE_WIDTH = 70
const App = () => {
const [activeIndex, setActiveIndex] = useState(3)
const [isAnimating, setIsAnimating] = useState(false)
const toPrev = () => {
if (isAnimating) return
setActiveIndex(prev => Math.max(0, prev - 1))
}
const toNext = () => {
if (isAnimating) return
setActiveIndex(prev => Math.min(ASSETS.length - 1, prev + 1))
}
const toSlide = (index) => {
if (isAnimating) return
setActiveIndex(index)
}
return (
<div className="p-2 text-neutral-800 select-none">
{/* carousel wrapper */}
<div className="relative" style={{ width: WIDTH }}>
{/* slides container */}
<motion.div
className="flex w-fit"
initial={false}
animate={{ x: -(IDLE_WIDTH * activeIndex) }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.8 }}
onAnimationStart={() => setIsAnimating(true)}
onAnimationComplete={() => setIsAnimating(false)}
>
{ASSETS.map((item, i) => {
const isActive = activeIndex === i
const rotateY = (i < activeIndex ? 1 : i > activeIndex ? -1 : 0) * 60
const rotateZ = (i < activeIndex ? 1 : i > activeIndex ? -1 : 0) * 90
{/* slide */}
return (
<div className="perspective-midrange" style={{ zIndex: ASSETS.length - Math.abs(activeIndex - i) }} key={i}>
<motion.div
className={`shrink-0 h-50 flex flex-col items-center gap-2 will-change-[transform] relative`}
animate={{
rotateY,
rotateZ,
width: isActive ? WIDTH : IDLE_WIDTH
}}
transition={{ type: 'tween', duration: 0.8, ease: [1, -0.03, 0.413, 0.965] }}
initial={false}
>
<div
className="absoute inset-0 size-50 bg-center bg-cover rounded-lg"
style={{ backgroundImage: `url(${item.src})` }}
onClick={() => toSlide(i)}></div>
</motion.div>
</div>
)
})}
</motion.div>
{/* slides frame */}
<motion.div
key={activeIndex}
className="absolute inset-0 m-auto size-54 border-2 border-gray-900 rounded-xl"
style={{ boxSizing: 'content-box' }}
animate={{ scale: [1, 1.07, 1] }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
></motion.div>
</div>
{/* controls */}
<div className="fixed bottom-4 left-0 right-0 w-fit px-2 mx-auto flex items-center gap-4 justify-center text-neutral-700 rounded-full bg-neutral-200/50 backdrop-blur-xs border border-neutral-200/80 shadow-sm z-50">
{/* prev button */}
<button onClick={toPrev} className="p-2 cursor-pointer">
<ChevronLeft />
</button>
{/* slide dots */}
<div className="w-[180px] flex justify-center items-center gap-2">
{ASSETS.map((_, i) => (
<div
key={i}
onClick={() => toSlide(i)}
className={`rounded-full cursor-pointer h-2 transition-[width,background-color] duration-300 ${activeIndex === i ? 'w-7 bg-current' : 'w-2 bg-current/30'}`}>
</div>
))}
</div>
{/* next button */}
<button onClick={toNext} className="p-2 cursor-pointer">
<ChevronRight />
</button>
</div>
</div>
)
}
const root = createRoot(document.getElementById("app"))
root.render(<App />)
'웹프로그램밍 자료실 > 기타 자료' 카테고리의 다른 글
| [해피CGI][cgimall] 움직이는 인풋 버튼 애니메이션 Material Radio Button (0) | 2026.10.08 |
|---|---|
| [해피CGI][cgimall] 스킬을 표현하기 좋은 애니메이션 소스 Stats animation (0) | 2026.10.07 |
| [해피CGI][cgimall] CSS로 구현하는 순차 등장 애니메이션 (0) | 2026.10.06 |
| [해피CGI][cgimall] 커서를 대항한 오픈소스 AI 코드 에디터 - voideditor (0) | 2026.09.23 |
| [해피CGI][cgimall] Zed v1.13.1 (0) | 2026.09.22 |

