퍼블리싱 로그/JS

[React] 이미지 카드 뿌리기

AI랑 노는 웹 퍼블리셔 2025. 8. 12. 11:16
반응형

 

리액트 UI 작업


썸네일 이미지 자동 뿌려짐

메뉴를 클릭하면 하단 콘텐츠가 좌→우로 펼쳐지고, 우측의 슬라이더로 속도(0.2–3.0s) 를 조절할 수 있고,
카드 개수와 크기를 UI에서 직접 조절할 수 있도록
구현한 코드

카드 개수: 1~20개 설정 가능
너비·높이(px): 각각 50~400 범위에서 변경

import React, { useState } from "react"; import { motion } from "framer-motion"; import { ChevronDown, Play, Zap } from "lucide-react";

const MENUS = [ "mn1", "mn2", "콘텐츠", "mn4", "mn5", ];

const Card = ({ index, width, height }: { index: number; width: number; height: number }) => (

  <div
    className="shrink-0 rounded-2xl border border-gray-300 bg-white shadow-sm grid place-items-center text-gray-700"
    style={{ width, height }}
  >
    <span className="text-sm">카드 {index + 1}</span>
  </div>
);export default function LeftToRightRevealDemo() { const [active, setActive] = useState<string>("콘텐츠"); const [duration, setDuration] = useState<number>(1.2); const [runId, setRunId] = useState(0); const [cardCount, setCardCount] = useState<number>(6); const [cardWidth, setCardWidth] = useState<number>(160); const [cardHeight, setCardHeight] = useState<number>(112);

const restart = () => setRunId((n) => n + 1);

const onMenuClick = (m: string) => { setActive(m); restart(); };

const clipStart = "inset(0 100% 0 0)"; const clipEnd = "inset(0 0% 0 0)"; const ease = [0.22, 1, 0.36, 1] as const;

return ( <div className="min-h-[520px] w-full bg-gray-50 text-gray-900 p-6"> <div className="mx-auto max-w-5xl"> <h1 className="text-xl font-semibold mb-4">좌 → 우 펼쳐지는 Reveal UI</h1>

<div className="flex flex-wrap items-center gap-4 rounded-2xl bg-white p-4 shadow-sm border">
      <div className="flex items-center gap-2">
        <Zap className="w-4 h-4" />
        <span className="text-sm text-gray-600">속도(초)</span>
        <input
          type="range"
          min={0.2}
          max={3}
          step={0.1}
          value={duration}
          onChange={(e) => setDuration(parseFloat(e.target.value))}
          className="w-40"
        />
        <span className="text-sm tabular-nums w-10 text-right">{duration.toFixed(1)}</span>
      </div>

      <div className="flex items-center gap-2">
        <span className="text-sm text-gray-600">카드 개수</span>
        <input
          type="number"
          min={1}
          max={20}
          value={cardCount}
          onChange={(e) => setCardCount(parseInt(e.target.value))}
          className="w-16 border rounded px-1 py-0.5 text-sm"
        />
      </div>

      <div className="flex items-center gap-2">
        <span className="text-sm text-gray-600">카드 너비(px)</span>
        <input
          type="number"
          min={50}
          max={400}
          value={cardWidth}
          onChange={(e) => setCardWidth(parseInt(e.target.value))}
          className="w-20 border rounded px-1 py-0.5 text-sm"
        />
      </div>

      <div className="flex items-center gap-2">
        <span className="text-sm text-gray-600">카드 높이(px)</span>
        <input
          type="number"
          min={50}
          max={400}
          value={cardHeight}
          onChange={(e) => setCardHeight(parseInt(e.target.value))}
          className="w-20 border rounded px-1 py-0.5 text-sm"
        />
      </div>

      <button
        onClick={restart}
        className="inline-flex items-center gap-2 rounded-xl bg-black/90 px-3 py-2 text-white text-sm shadow hover:bg-black"
      >
        <Play className="w-4 h-4" /> 다시 재생
      </button>
    </div>

    <div className="mt-6 grid grid-cols-5 gap-3">
      {MENUS.map((m) => (
        <button
          key={m}
          onClick={() => onMenuClick(m)}
          className={
            "rounded-2xl border bg-white px-4 py-3 text-center text-sm shadow-sm transition " +
            (active === m
              ? "border-black/80 font-semibold"
              : "border-gray-300 hover:border-gray-400")
          }
        >
          {m}
        </button>
      ))}
    </div>

    <div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
      <ChevronDown className="w-3 h-3" />
      <span>메뉴를 클릭하면 하단 콘텐츠가 좌측에서 우측으로 펼쳐집니다.</span>
    </div>

    <div className="relative mt-12">
      <div className="h-px w-full bg-gray-300" />

      <motion.div
        key={`${active}-${runId}-${duration}-${cardCount}-${cardWidth}-${cardHeight}`}
        initial={{ clipPath: clipStart }}
        animate={{ clipPath: clipEnd }}
        transition={{ duration, ease }}
        className="absolute inset-0 overflow-visible"
      >
        <div className="-top-16 absolute left-0 right-0 flex items-center gap-8">
          {[...Array(cardCount)].map((_, i) => (
            <Card key={i} index={i} width={cardWidth} height={cardHeight} />
          ))}
        </div>

        <motion.div
          initial={{ width: 0 }}
          animate={{ width: "100%" }}
          transition={{ duration, ease }}
          className="h-[3px] bg-gray-800"
        />
      </motion.div>
    </div>
  </div>
</div>

); }




 

App.jsx

 

import { useState } from "react";

const demoImages = Array.from(
  { length: 12 },
  (_, i) => `https://picsum.photos/seed/${i + 1}/400/400`
);

export default function App() {
  // 메뉴 상태
  const [active, setActive] = useState(false);

  // 커스터마이즈 옵션
  const CARD_SIZE = 140; // 카드 한 변(px)
  const STAGGER_MS = 120; // 순차 지연 간격(ms)
  const ANIM_DURATION_MS = 420; // 카드 1장 애니메이션 시간(ms)

  const handleMenuClick = () => {
    // 다시 클릭 시에도 처음부터 재생하고 싶다면 아래 두 줄 사용
    setActive(false);
    requestAnimationFrame(() => setActive(true));

    // 단순 토글이면: setActive(v => !v)
  };

  return (
    <div
      style={{
        fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
      }}
    >
      {/* 상단 메뉴 */}
      <div
        style={{
          position: "sticky",
          top: 0,
          zIndex: 1,
          display: "flex",
          gap: 8,
          padding: "12px 16px",
          borderBottom: "1px solid #eee",
          background: "#fff",
        }}
      >
        <button
          onClick={handleMenuClick}
          style={{
            padding: "10px 14px",
            borderRadius: 10,
            border: "1px solid #ddd",
            background: "#111",
            color: "#fff",
            cursor: "pointer",
          }}
        >
          콘텐츠 보기
        </button>
        {/* (선택) 속도 조절 슬라이더 예시 */}
        {/* <input type="range" min="60" max="300" value={STAGGER_MS} onChange={()=>{}} /> */}
      </div>

      {/* 하단 카드 영역: 가로 스크롤 */}
      <div
        style={{
          padding: 16,
          overflowX: "auto",
          whiteSpace: "nowrap",
        }}
      >
        <div style={{ display: "inline-flex", gap: 16 }}>
          {demoImages.map((src, idx) => (
            <Card
              key={src}
              src={src}
              size={CARD_SIZE}
              // index에 따라 각 카드의 애니메이션 시작을 지연
              delayMs={idx * STAGGER_MS}
              durationMs={ANIM_DURATION_MS}
              play={active}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function Card({
  src,
  size = 140,
  delayMs = 0,
  durationMs = 420,
  play = false,
}) {
  return (
    <div
      className={`card ${play ? "card-in" : ""}`}
      style={{
        width: size,
        height: size,
        borderRadius: 14,
        border: "1px solid #e5e7eb",
        overflow: "hidden",
        background: "#f8fafc",
        // 애니메이션 제어
        animationDelay: `${delayMs}ms`,
        animationDuration: `${durationMs}ms`,
      }}
    >
      <img
        src={src}
        alt=""
        draggable="false"
        style={{ width: "100%", height: "100%", objectFit: "cover" }}
      />
    </div>
  );
}

 

app.css
.App {
  font-family: sans-serif;
  text-align: center;
}

/* 기본 */
.card {
  opacity: 0;
  transform: translateX(-140px) translateY(6px) scale(0.92) rotateZ(-1deg);
  filter: blur(6px);
  will-change: transform, opacity, filter;
  transform-origin: left center;
}

/* 메뉴 클릭 후 순차 등장: '날아오는' 모션 */
.card.card-in {
  animation-name: flyInLTR;
  animation-fill-mode: both;

  /* duration, delay는 인라인으로 카드별 지정됨 */
  animation-timing-function: cubic-bezier(
    0.23,
    0.89,
    0.43,
    1.22
  ); /* 살짝 튀듯이 */
}

@keyframes flyInLTR {
  0% {
    opacity: 0;
    transform: translateX(-140px) translateY(6px) scale(0.92) rotateZ(-1.5deg);
    filter: blur(6px);
  }

  60% {
    /* 살짝 앞으로 치고 나감(오버슈트) */
    opacity: 1;
    transform: translateX(8px) translateY(0) scale(1.02) rotateZ(0deg);
    filter: blur(1.5px);
  }

  100% {
    opacity: 1;
    transform: translateX(0) translateY(0) scale(1) rotateZ(0deg);
    filter: blur(0);
  }
}

/* 스크롤바(선택) */
::-webkit-scrollbar {
  height: 8px;
}

::-webkit-scrollbar-thumb {
  background: #e5e7eb;
  border-radius: 8px;
}

/* 모션 줄이기 설정을 존중 */
@media (prefers-reduced-motion: reduce) {
  .card {
    transform: none !important;
    filter: none !important;
  }

  .card.card-in {
    animation: fadeIn 300ms both;
  }

  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}

 

결과화면

 

결과 화면

반응형