[CloneCoding] 룰렛 슬라이드 만들기

Suvina·2026년 6월 8일

React

목록 보기
24/25


maximatherapy clone coading ...

🎡 코드 먼저

import { useRef, useState, useCallback, useEffect } from "react";
import gsap from "gsap";
import { SLIDES, SLIDE_COUNT, STEP } from "../data/slides";
import SliceArt from "./SliceArt";
import styles from "./RouletteSlider.module.css";

// 슬라이스 하나: 부채꼴 배경 + SVG 일러스트
interface SliceProps {
  index: number;
  rotationDeg: number; // 이 슬라이스의 초기 배치 각도
}

function Slice({ index, rotationDeg }: SliceProps) {
  const clipId = `clip-${index}`;
  return (
    <div
      className={styles.slice}
      style={{ transform: `rotate(${rotationDeg}deg)` }}
    >
      <svg viewBox="0 0 800 800" className={styles.sliceSvg}>
        <defs>
          {/*
            부채꼴 클리핑: 하단 중앙(피벗)에서 위로 펼쳐지는 삼각형
            wheel의 transform-origin(50% 100%)과 맞물려 룰렛처럼 보임
          */}
          <clipPath id={clipId}>
            <polygon points="400,800 100,0 700,0" />
          </clipPath>
        </defs>
        <SliceArt index={index} clipId={clipId} />
      </svg>
    </div>
  );
}

// ──────────────────────────────────────────────
// SliderLabel: 현재 슬라이드 정보 표시
// ──────────────────────────────────────────────
interface LabelProps {
  title: string;
  tag: string;
  bg: string;
  fg: string;
}

function SliderLabel({ title, tag, bg, fg }: LabelProps) {
  return (
    <div className={styles.label} id="roulette-label">
      <div className={styles.labelBox} style={{ background: bg }}>
        <p className={styles.labelTitle} style={{ color: fg }}>
          {title}
        </p>
        <span
          className={styles.labelTag}
          style={{ borderColor: fg, color: fg }}
        >
          {tag}
        </span>
      </div>
    </div>
  );
}
──────────────────────────────────────────────
// RouletteSlider (메인 컴포넌트)
// ──────────────────────────────────────────────
export default function RouletteSlider() {
  const wheelRef = useRef<HTMLDivElement>(null);
  const [cur, setCur] = useState(0);
  const [label, setLabel] = useState(SLIDES[0]);

  // 누적 회전각 (ref로 관리 → 리렌더 없이 GSAP에 전달)
  const wheelAngle = useRef(0);
  const busy = useRef(false);
  const startX = useRef(0);

  // ── 슬라이드 전환 ─────────────────────────────
  const goTo = useCallback((next: number) => {
   
    if (busy.current) return;

    const total = SLIDE_COUNT;
    const dir = next > cur ? 1 : -1;
    next = ((next % total) + total) % total;
    if (next === cur) return;

    busy.current = true;
    wheelAngle.current += STEP * dir;

    // 핵심: #wheel 하나만 회전 → 도형·배경 모두 같이 돌아감
    gsap.to(wheelRef.current, {
      rotation: wheelAngle.current,
      duration: 0.85,
      ease: "power3.inOut",
      onComplete: () => {
        busy.current = false;
      },
    });

    // 레이블 fade 전환
    gsap.to("#roulette-label", {
      opacity: 0,
      y: 10,
      duration: 0.18,
      onComplete: () => {
        setLabel(SLIDES[next]);
        setCur(next);
        gsap.to("#roulette-label", { opacity: 1, y: 0, duration: 0.28 });
      },
    });
  }, [cur]);

  // ── 드래그 (Pointer Events) ────────────────────
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
    startX.current = e.clientX;
  }, []);

  const handlePointerUp = useCallback(
    (e: React.PointerEvent) => {
      const dx = e.clientX - startX.current;
      if (Math.abs(dx) > 50) goTo(dx < 0 ? cur + 1 : cur - 1);
    },
    [cur, goTo]
  );

  // ── 키보드 접근성 ─────────────────────────────
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === "ArrowRight") goTo(cur + 1);
      if (e.key === "ArrowLeft") goTo(cur - 1);
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [cur, goTo]);

  return (
    <div
      className={styles.slider}
      onPointerDown={handlePointerDown}
      onPointerUp={handlePointerUp}
      onDragStart={(e) => e.preventDefault()}
      role="region"
      aria-label="프로그램 슬라이더"
    >
      {/*
        #wheel: 이 요소만 gsap으로 rotate
        transform-origin: 50% 100% (하단 중앙 = 룰렛 축)
        크기를 뷰포트보다 훨씬 크게 → 회전 시 빈공간 노출 방지
      */}
      <div className={styles.wheel} ref={wheelRef}>
        {SLIDES.map((_, i) => (
          <Slice key={i} index={i} rotationDeg={i * STEP} />
        ))}
      </div>

      {/* 네비게이션 버튼 */}
      <button
        className={`${styles.navBtn} ${styles.navBtnPrev}`}
        onClick={() => goTo(cur - 1)}
        aria-label="이전 슬라이드"
      >
        ←
      </button>
      <button
        className={`${styles.navBtn} ${styles.navBtnNext}`}
        onClick={() => goTo(cur + 1)}
        aria-label="다음 슬라이드"
      >
        →
      </button>

      {/* 레이블 */}
      <SliderLabel
        title={label.title}
        tag={label.tag}
        bg={label.labelBg}
        fg={label.labelFg}
      />

      {/* 인디케이터 dots */}
      <div className={styles.dots} role="tablist">
        {SLIDES.map((_, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === cur}
            aria-label={`슬라이드 ${i + 1}`}
            className={`${styles.dot} ${i === cur ? styles.dotActive : ""}`}
            onClick={() => goTo(i)}
          />
        ))}
      </div>
    </div>
  );
}

룰렛 슬라이더의 회전 원리

1. 구조부터 이해하기

[slider] ← 보이는 영역
  └ [wheel] ← GSAP이 회전시키는 대상
  	   ├ [Slice 0] → rotate(0deg)
       ├ [Slice 1] → rotate(STEP deg)
       ├ [Slice 2] → rotate(STEP * 2 deg)
       └ ...

wheel 하나만 돌린다는 게 핵심이에요. 슬라이스들은 wheel 안에 고정되어 있어서, wheel이 돌면 전부 같이 돌아가요.


2. transform-origin: 50% 100% - 피벗 포인트

┌─────────────┐
│            │
│    wheel   │
│            │
└──────●──────┘  ← 이 점을 중심으로 회전

보통 요소는 중앙(50% 50%) 을 기준으로 회전하는데, 여기선 하단 중앙(50% 100%) 이 기준이에요.
이게 룰렛처럼 보이는 이유예요. 피벗이 아래에 있으니까 부채꼴들이 위로 펼쳐지면서 돌아가는 것처럼 보이죠.


3. wheelAngle.current - 누적 각도를 ref로 관리

const wheelAngle = useRef(0); // 현재까지 총 회전한 각도
wheelAngle.current += STEP * dir; // 다음 각도 = 기존 + 한 슬라이스 각도

왜 state가 아니라 ref냐면 GSAP한테 전달할 값이라 리렌더가 필요 없어서요.
state로 하면 값 바뀔 때마다 불필요하게 리렌더가 발생해요.
누적으로 관리하는 이유는, GSAP의 rotation은 항상 절대값으로 받기 때문이에요.

// 매번 "지금부터 N도 더 돌아"가 아니라
// "총N도인 지점까지 돌아" 로 지시
gsap.to(wheelRef.current, {
	rotation: wheelAngle.current, // 절대 각도
    duration: 0.85,
    ease: "power3.inOut",
})

예를 들어 슬라이드 3번 이동했으면 STEP 3 을 넘기는 거에요.
"앞으로 STEP만큼"이 아니라 "총 STEP
3 지점으로"가라는 거죠. 이렇게 해야 GSAP이 중간에 끊기지 않고 부드럽게 이어서 애니메이션할 수 있어요.


4. busy.current - 애니메이션 중 입력 차단

const busy= useRef(false);

const goTo = (next) => {
	if(busy.current) return; // 돌아가는 중이면 무시
	
    busy.current = true;
    gsap.to(wheel, {
    	// ...
        onComplete: () => {
        	busy.current = false; // 다 돌았으면 다시 허용
        }
    })
}

애니메이션 도중에 연속 클릭하면 각도가 꼬일 수 있어서 막아두는 거예요.
이것도 리렌더가 필요 없는 플래그라서 ref로 관리해요.


5. 레이블 fade는 별도로

gsap.to ("#roulette=label", {
	opacity: 0, y: 10, duration: 0.18,
    onComplete: () => {
    	setLabel(SLIDES[next]); // wheel 회전 중간에 텍스트 교체
        gsap.to("#roulette-label", {opacity: 1, y: 0});
    },
});

wheel이 돌아가는 도중에 텍스트를 몰래 바꿔치기 하는 패턴이에요.
opacity가 0인 순간에 교체하니까 사용자 눈에는 자연스러운 fade처럼 보이죠.


전체 흐름 요약

goTo(next) 호출
  ↓
busy 체크 → 애니메이션 중이면 return
  ↓
wheelAngle += STEP * dir  (누적 각도 계산)
  ↓
GSAP: wheel을 wheelAngle까지 회전 (0.85s)
  ↓ (병렬)
GSAP: label fade out → 텍스트 교체 → fade in
  ↓
onComplete: busy = false

완성본..에셋을 구할 수 없어서 기능만 흉내 내보았다.
profile
개인공부

0개의 댓글