웹솔루션개발 26년 노하우! 해피CGI의 모든것

[해피CGI][cgimall] 입체적인 이미지 카드 캐러셀 본문

웹프로그램밍 자료실/기타 자료

[해피CGI][cgimall] 입체적인 이미지 카드 캐러셀

해피CGI윤실장 2026. 10. 12. 09:22

이미지 카드를 겹쳐 배치한 뒤 선택한 카드가 중앙에 표시되도록 구성한 캐러셀입니다.
이전·다음 버튼이나 하단 인디케이터를 이용해 원하는 이미지를 선택할 수 있습니다.
카드가 이동하면서 회전과 크기 변화가 함께 적용되어 자연스럽고 입체적인 전환 효과를 보여줍니다.

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 = [

  {

    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 />)

 

Comments