# AI 꾸러미 — 공유 요소 전환 (FLIP · 목록 → 상세) — FLIP animation (First, Last, Invert, Play)
> 목록의 카드를 누르면 그 그림 · 제목이 제자리에서 커져 상세 화면이 된다 — 처음 · 끝 위치를 재고 거꾸로 돌려 놓은 뒤 풀어 「같은 물건」으로 이어 보이게.  
> 견본: https://ai-techstudio.web.app/#t/i548

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 공유 요소 전환 (FLIP · 목록 → 상세) — FLIP animation (First, Last, Invert, Play)

#### 1. 목표
게임 고르기 목록 → 게임 상세에서 카드를 누르면 그 카드의 그림 · 제목이 제자리에서 커져 상세 화면이 되는 공유 요소 전환(FLIP)을 만들어 줘. 느낌은 밝은 카드 · 살짝 출렁이는 스프링.

#### 2. 핵심 기술 용어
- **FLIP animation (First, Last, Invert, Play)** — 처음 재기 → 배치 바꾸고 끝 재기 → 차이만큼 거꾸로 transform → 0 으로 풀기
- **Shared element transition** — 두 화면에 같이 있는 요소가 이어서 움직이는 전환
- **View Transitions API (view-transition-name)** — 브라우저가 FLIP 을 대신 해 주는 표준 — document.startViewTransition
- **Spring easing** — 살짝 지나쳤다 돌아오는 감쇠 진동 곡선

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 배치(left · top · width · height 또는 클래스)는 실제로 바꾸고, 움직임은 transform · opacity 로만. 지원 브라우저에선 document.startViewTransition + view-transition-name 으로 같은 효과를 낼 수 있다.

#### 4. 조건
- FLIP 네 단계를 지킬 것: 바꾸기 전 측정 → 배치 실제로 바꾸기 → 다시 측정 → 차이를 transform 으로 거꾸로 놓고 풀기
- 움직이는 동안 left · top · width · height 를 매 프레임 바꾸지 말 것 — transform · opacity 만
- transform-origin 은 왼쪽 위(0 0) — 이동 · 배율 계산과 맞게
- 글자 요소는 같은 배율(높이 비율)로만 키울 것. 그림 · 제목을 따로 FLIP 해야 글자가 찌그러지지 않는다
- 닫을 때도 같은 방법으로 거꾸로 (상세 → 목록). 열기 0.6~0.8초, 닫기는 조금 더 빠르게

#### 5. 완성 기준 (이게 보이면 성공)
- 카드를 누르면 그 카드의 그림이 끊김 없이 커져 화면 위쪽을 꽉 채우고, 제목이 그 아래 큰 글씨 자리로 날아간다
- 전환 켜기/끄기를 바꾸면 끈 쪽은 화면이 뚝 바뀌고, 켠 쪽은 어느 카드를 눌렀는지 눈으로 따라갈 수 있다
- 제목 글자가 움직이는 동안 가로로 늘어나거나 찌그러지지 않는다
- 「← 목록」을 누르면 그림 · 제목이 원래 카드 자리로 정확히 돌아가 들어간다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- First: 바꾸기 전에 그림 · 제목의 위치와 크기를 잰다. Last: 배치를 상세 화면으로 실제로 바꾸고 다시 잰다.
- Invert: 이제 요소는 이미 상세 자리에 있다. transform 으로 「처음 자리 · 크기」로 돌려 놓는다 — 이동 = 처음 − 끝, 배율 = 처음 크기 ÷ 끝 크기.
- Play: transform 을 0(이동) · 1(배율)로 풀면 요소가 처음 자리에서 끝 자리로 날아간다. 배치 계산은 한 번뿐이고 움직이는 동안은 transform 만 바뀌어 가볍다.
- 글자는 가로 · 세로를 같은 배율로 — 따로 늘리면 찌그러진다. 나머지 카드는 흐려지며 물러나고, 상세 내용은 조금 늦게 차례로 나타난다.

## 핵심 코드 — FLIP — 재고, 바꾸고, 다시 재고, 거꾸로 놓고 풀기
(발췌: demos/demosMotionC.ts i548 의 go() 를 정리)
```ts
function flip(el: HTMLElement, change: () => void, dur = 700, keepAspect = false) {
  const f = el.getBoundingClientRect();          // First
  change();                                      // 배치를 실제로 바꾼다 (클래스 · 부모 바꾸기)
  const l = el.getBoundingClientRect();          // Last
  const sy = f.height / l.height;
  const s = keepAspect ? [sy, sy] : [f.width / l.width, sy];  // 글자는 같은 배율로
  el.style.transformOrigin = '0 0';
  el.animate(
    [
      { transform: 'translate(' + (f.left - l.left) + 'px,' + (f.top - l.top) + 'px) scale(' + s[0] + ',' + s[1] + ')' }, // Invert
      { transform: 'none' },                                                                                              // Play
    ],
    { duration: dur, easing: 'cubic-bezier(.2,1.25,.35,1)' },  // 살짝 지나쳤다 돌아옴
  );
}

// 카드를 누르면 그림과 제목을 따로 FLIP
card.onclick = () => {
  flip(thumb, () => thumb.classList.add('open'));
  flip(title, () => title.classList.add('open'), 700, true);
};
```

## 흔한 실수 · 확인 목록
- [ ] **측정 사이에 배치를 안 바꾸면 차이가 0 이 된다** — First 와 Last 사이에 실제 배치 변경(클래스 · 스타일)이 있어야 한다. 바꾸는 코드를 requestAnimationFrame 뒤로 미루면 두 측정 값이 같아져 아무 일도 없다.
- [ ] **글자를 가로 · 세로 다른 배율로 키우면 찌그러진다** — 상자 비율이 바뀌면 sx ≠ sy 다. 견본처럼 그림과 제목을 따로 FLIP 하고 제목은 높이 비율 하나로 키운다.
- [ ] **모서리 둥글기도 같이 늘어난다** — scale 은 border-radius 까지 늘린다. 둥근 모서리가 중요하면 clip-path: inset(… round r) 를 따로 애니하거나 안쪽 요소에 반대 배율을 준다.
- [ ] **움직이는 중에 다시 누르면 엉뚱한 자리에서 시작한다** — 진행 중인 애니를 먼저 끝내고(transform 지우기) 측정한다. 견본은 go() 첫 줄에서 finish() 를 부른다.
- [ ] **닫힐 때 다른 카드 밑으로 숨는다** — 움직이는 동안 z-index 를 올려 두고 끝나면 되돌린다.

## 완성 기준 체크리스트
- [ ] 카드를 누르면 그 카드의 그림이 끊김 없이 커져 화면 위쪽을 꽉 채우고, 제목이 그 아래 큰 글씨 자리로 날아간다
- [ ] 전환 켜기/끄기를 바꾸면 끈 쪽은 화면이 뚝 바뀌고, 켠 쪽은 어느 카드를 눌렀는지 눈으로 따라갈 수 있다
- [ ] 제목 글자가 움직이는 동안 가로로 늘어나거나 찌그러지지 않는다
- [ ] 「← 목록」을 누르면 그림 · 제목이 원래 카드 자리로 정확히 돌아가 들어간다

## 이 기술 정보
- id: `i548` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 배치 측정은 전환 순간 두 번뿐. 움직이는 동안은 transform · opacity 만 바뀌어 합성 단계에서 처리된다 — 폰에서도 60fps.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i548
- 쓰면 좋을 때: 목록 → 상세처럼 「같은 물건을 크게 본다」는 느낌이 중요할 때 / 레이아웃이 바뀌는 순간(칸 정렬 · 줄 추가 · 순위 바뀜)을 부드럽게 이어 줄 때
- 쓰지 말 때: 두 화면에 같은 요소가 없을 때 — 대신 밀기 · 덮기 전환(i99 마스크 와이프) / 움직임을 싫어하는 사람(prefers-reduced-motion) — 대신 짧은 흐려짐 전환

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i548 — `src/demos/demosMotionC.ts:12`
```ts
const i548 = {
  kind: 'dom' as const,
  caption: '카드를 누르면 그림 · 제목이 제자리에서 커져 상세 화면이 돼요 — 처음 · 끝 위치를 재고, 거꾸로 돌려 놓은 뒤 풀어 주는 FLIP',
  make(box: HTMLElement) {
    const o = { s: 1, flip: true, bounce: 0.5 };
    const NAMES = ['별 모으기', '주사위 굴리기', '숫자 야구', '용 둥지', '얼음 별', '블록 쌓기'];
    const COL = [['#ffb547', '#ff6a3d'], ['#5ad1ff', '#3a6bff'], ['#7be08a', '#1fa36b'], ['#c08bff', '#6a3dff'], ['#9ff0ff', '#4aa8d8'], ['#ff8fb1', '#e2457a']];
    const ORDER = [4, 0, 2, 5, 1, 3];
    const gx = (i: number): number => 12 + (i % 3) * 88;
    const gy = (i: number): number => 26 + Math.floor(i / 3) * 74;
    let h = '<div class="mc48-hd">게임 고르기</div>';
    for (let i = 0; i < 6; i++) h += `<div class="mc48-cd" style="left:${gx(i)}px;top:${gy(i)}px"></div>`;
    for (let i = 0; i < 6; i++) {
      const c = COL[i]!;
      h += `<div class="mc48-th" style="background:radial-gradient(ellipse 22% 34% at 72% 40%,rgba(255,255,255,.55) 96%,transparent 100%),radial-gradient(ellipse 11% 18% at 28% 68%,rgba(255,255,255,.32) 96%,transparent 100%),linear-gradient(135deg,${c[0]},${c[1]})"></div>`;
    }
    for (let i = 0; i < 6; i++) h += `<div class="mc48-ti">${NAMES[i]}</div>`;
    h += '<div class="mc48-d mc48-bk">← 목록</div><div class="mc48-d mc48-l" style="top:124px;width:210px"></div><div class="mc48-d mc48-l" style="top:134px;width:150px"></div><div class="mc48-d mc48-go">시작하기</div><div class="mc48-tap"></div>';
    const S = stage(
      box,
      `.mc48-hd{position:absolute;left:12px;top:7px;font:800 10px ${F};color:#1d2b4f}
      .mc48-cd{position:absolute;width:80px;height:68px;border-radius:9px;background:#fff;box-shadow:0 2px 6px rgba(30,50,110,.15)}
      .mc48-th{position:absolute;transform-origin:0 0}
      .mc48-ti{position:absolute;font-weight:800;font-family:${F};color:#1d2b4f;white-space:nowrap;line-height:1.2;transform-origin:0 0}
      .mc48-d{position:absolute;opacity:0;z-index:6}
      .mc48-bk{left:8px;top:8px;padding:2px 7px;border-radius:9px;background:rgba(0,0,0,.35);color:#fff;font:700 8px ${F}}
      .mc48-l{left:14px;height:5px;border-radius:3px;background:#d5dcef}
      .mc48-go{left:14px;top:147px;width:72px;height:18px;border-radius:9px;background:#3a6bff;color:#fff;font:800 8.5px/18px ${F};text-align:center}
      .mc48-tap{position:absolute;left:0;top:0;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;border:2px solid #3a6bff;background:rgba(58,107,255,.18);opacity:0;z-index:9;box-sizing:border-box}`,
      h,
      '#eef2fb',
    );
    const q = (s: string): HTMLElement[] => [...S.st.querySelectorAll<HTMLElement>(s)];
    const cd = q('.mc48-cd');
    const th = q('.mc48-th');
    const ti = q('.mc48-ti');
    const det = q('.mc48-d');
    const tap = S.st.querySelector<HTMLElement>('.mc48-tap')!;
    const dv = det.map(() => 0);

    function setLayout(i: number, open: boolean): void {
      const a = th[i]!.style;
      const b = ti[i]!.style;
      if (open) {
        a.left = '0px'; a.top = '0px'; a.width = '280px'; a.height = '88px'; a.borderRadius = '0px';
        b.left = '14px'; b.top = '97px'; b.fontSize = '16px';
      } else {
        a.left = `${gx(i) + 4}px`; a.top = `${gy(i) + 4}px`; a.width = '72px'; a.height = '42px'; a.borderRadius = '6px';
        b.left = `${gx(i) + 6}px`; b.top = `${gy(i) + 51}px`; b.fontSize = '9px';
      }
    }
    for (let i = 0; i < 6; i++) setLayout(i, false);

    // ── FLIP: First(처음 재기) → Last(바꾸고 다시 재기) → Invert(거꾸로 돌려 놓기) → Play(풀어 주기)
    const R = (e: HTMLElement): number[] => [e.offsetLeft, e.offsetTop, e.offsetWidth, e.offsetHeight];
    let an: { i: number; t0: number; dur: number; a: number[]; b: number[] } | null = null;
    let cur = -1;
    let openT = 0;
    let tt = 0;
    let fd = 0;
    function finish(): void {
      if (!an) return;
      th[an.i]!.style.transform = ti[an.i]!.style.transform = '';
      th[an.i]!.style.zIndex = ti[an.i]!.style.zIndex = an.i === cur ? '5' : '';
      an = null;
    }
    function go(i: number, open: boolean): void {
      finish();
      const f1 = R(th[i]!);
      const f2 = R(ti[i]!);
      setLayout(i, open);
      const l1 = R(th[i]!);
      const l2 = R(ti[i]!);
      th[i]!.style.zIndex = ti[i]!.style.zIndex = open ? '5' : '4';
      if (!o.flip) {
        th[i]!.style.zIndex = ti[i]!.style.zIndex = open ? '5' : '';
        return;
      }
      const sT = f2[3]! / l2[3]!; // 글자는 같은 배율로 — 가로세로 따로 늘리면 찌그러진다
      an = { i, t0: tt, dur: open ? 0.75 : 0.6, a: [f1[0]! - l1[0]!, f1[1]! - l1[1]!, f1[2]! / l1[2]!, f1[3]! / l1[3]!], b: [f2[0]! - l2[0]!, f2[1]! - l2[1]!, sT, sT] };
    }
    const spring = (k: number): number => {
      if (k >= 1) return 1;
      const w = 7 + o.bounce * 9;
      const d = 8 - o.bounce * 4;
      return 1 - Math.exp(-d * k) * Math.cos(w * k) * (1 - k * k * k);
    };
    const put = (e: HTMLElement, v: number[], k: number): void => {
      e.style.transform = `translate(${(v[0]! * (1 - k)).toFixed(2)}px,${(v[1]! * (1 - k)).toFixed(2)}px) scale(${lerp(v[2]!, 1, k).toFixed(4)},${lerp(v[3]!, 1, k).toFixed(4)})`;
    };

    return {
      controls: [
        speedCtl(o),
        { type: 'toggle', label: 'FLIP 로 이어 주기 (끄면 뚝 바뀜)', value: true, on: (v: boolean) => (o.flip = v) },
        { type: 'range', label: '스프링 출렁임', min: 0, max: 1, step: 0.05, value: 0.5, on: (v: number) => (o.bounce = v) },
      ] as Control[],
      update(_t: number, dt: number) {
        const d = Math.max(0, Math.min(dt, 0.1)) * o.s;
        tt += d;
        const T = 5.6;
        const c = Math.floor(tt / T);
        const p = tt - c * T;
        const sel = ORDER[c % 6]!;
        const want = p >= 1.25 && p < 4.1 ? sel : -1;
        if (want !== cur) {
          const prev = cur;
          cur = want;
          if (prev >= 0) go(prev, false);
          if (want >= 0) {
            go(want, true);
            openT = tt;
          }
        }
        if (an) {
          const k = (tt - an.t0) / an.dur;
          if (k >= 1) finish();
          else {
            const e = spring(k);
            put(th[an.i]!, an.a, e);
            put(ti[an.i]!, an.b, e);
          }
        }
        // 나머지 카드는 물러나고, 상세 내용은 차례로
        fd += ((cur >= 0 ? 1 : 0) - fd) * (1 - Math.exp(-d * 12));
        const hero = cur >= 0 ? cur : an ? an.i : -1;
        for (let j = 0; j < 6; j++) {
          const op = (1 - fd).toFixed(3);
          cd[j]!.style.opacity = op;
          cd[j]!.style.transform = `scale(${(1 - 0.05 * fd).toFixed(4)})`;
          if (j !== hero) th[j]!.style.opacity = ti[j]!.style.opacity = op;
          else th[j]!.style.opacity = ti[j]!.style.opacity = '1';
        }
        det.forEach((e, j) => {
          const v = cur >= 0 ? outCubic(seg(tt - openT, 0.3 + 0.07 * j, 0.62 + 0.07 * j)) : Math.max(0, dv[j]! - d * 8);
          dv[j] = v;
          e.style.opacity = v.toFixed(3);
          e.style.transform = `translateY(${((1 - v) * 6).toFixed(2)}px)`;
        });
        // 누르는 손가락 자리 — 카드를 누르고, 「← 목록」을 누른다
        const tap1 = seg(p, 0.6, 0.8) * (1 - seg(p, 1.18, 1.32));
        const tap2 = seg(p, 3.45, 3.65) * (1 - seg(p, 4.03, 4.17));
        const on1 = tap1 > 0;
        const tp = on1 ? p - 1.05 : p - 3.9;
        const sc = 1 - 0.3 * seg(tp, 0, 0.1) + 0.9 * seg(tp, 0.1, 0.27);
        tap.style.opacity = Math.max(tap1, tap2).toFixed(3);
        tap.style.transform = on1 ? `translate(${gx(sel) + 40}px,${gy(sel) + 30}px) scale(${sc.toFixed(3)})` : `translate(30px,15px) scale(${sc.toFixed(3)})`;
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [스프링 · 감쇠 움직임 (임계 감쇠)](https://ai-techstudio.web.app/ai/t/i293.md) `i293` · [이징 곡선 모음 (트윈 비교)](https://ai-techstudio.web.app/ai/t/i292.md) `i292`
- 다음에 해 볼 기술: [알림 · 창 등장 묶음 (쌓이는 알림 · 뒤 흐림 창 · 차례 등장)](https://ai-techstudio.web.app/ai/t/i551.md) `i551` · [데이터 애니 (막대 경주 · 자라는 차트)](https://ai-techstudio.web.app/ai/t/i116.md) `i116`
- 참고 문서: [Paul Lewis — FLIP Your Animations](https://aerotwist.com/blog/flip-your-animations/) · [MDN — View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) · [MDN — Element.animate()](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate)
