À̹ÌÁö Ä«µå¸¦ °ãÃÄ ¹èÄ¡ÇÑ µÚ ¼±ÅÃÇÑ Ä«µå°¡ Áß¾Ó¿¡ Ç¥½ÃµÇµµ·Ï ±¸¼ºÇÑ Ä³·¯¼¿ÀÔ´Ï´Ù.
ÀÌÀü·´ÙÀ½ ¹öưÀ̳ª ÇÏ´Ü ÀεðÄÉÀÌÅ͸¦ ÀÌ¿ëÇØ ¿øÇÏ´Â À̹ÌÁö¸¦ ¼±ÅÃÇÒ ¼ö ÀÖ½À´Ï´Ù.
Ä«µå°¡ À̵¿ÇÏ¸é¼ È¸Àü°ú Å©±â º¯È°¡ ÇÔ²² Àû¿ëµÇ¾î ÀÚ¿¬½º·´°í ÀÔüÀûÀÎ Àüȯ È¿°ú¸¦ º¸¿©ÁÝ´Ï´Ù.
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 = [
{
src: 'https://images.unsplash.com/photo-1644186171024-1bacda31db33?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'A blurry photo of a car\'s tail light photo',
},
{
src: 'https://images.unsplash.com/photo-1573750328968-179cc634660a?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Shallow focus photo of person holding video camera photo',
},
{
src: 'https://images.unsplash.com/photo-1611518757417-b5fa0838b12e?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Silhouette of man sitting on chair photo',
},
{
src: 'https://images.unsplash.com/photo-1625690303837-654c9666d2d0?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Black and gray camera tripod photo',
},
{
src: 'https://images.unsplash.com/photo-1541869440787-abe7669a951a?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'A blurry photo of a tree with a blue sky in the background photo',
},
{
src: 'https://images.unsplash.com/photo-1611518050831-f945a6f6a1b3?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Woman in black and white floral shirt photo',
},
{
src: 'https://images.unsplash.com/photo-1602043469092-aa55df5a8aae?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Person holding silver round ornament photo',
},
{
src: 'https://images.unsplash.com/photo-1603993138561-9151852b44e1?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Person holding clear glass ball photo',
},
{
src: 'https://images.unsplash.com/photo-1611518016416-b544a3ee13ce?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
title: 'Woman in red and black dress photo'
},
{
src: 'https://images.unsplash.com/photo-1603993097397-89c963e325c7?q=80&w=500&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
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 />)