# AI 꾸러미 — 화면 전환 효과 (카드가 화면으로 커지기) — Shared element transition
> 누른 카드의 자리 · 크기 · 모서리를 화면 전체로 이어 키우고 나머지는 옅게 사라지게 해서, 메뉴에서 게임으로 화면이 툭 바뀌지 않고 이어진다.  
> 견본: https://ai-techstudio.web.app/#t/i23

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

## 주문서

### 만들어 줘: 화면 전환 효과 (카드가 화면으로 커지기) — Shared element transition

#### 1. 목표
게임 고르기 메뉴 → 게임 화면에서 누른 카드가 그대로 커져 다음 화면이 되게 해 줘 — 나머지 카드는 옅어지고, 닫을 때는 거꾸로 카드로 돌아가게. 분위기는 밝은 카드 메뉴.

#### 2. 핵심 기술 용어
- **Shared element transition** — 같은 요소가 이어지며 바뀌는 화면 전환
- **View Transitions API** — 브라우저 기본 화면 전환 (document.startViewTransition)
- **FLIP animation (First · Last · Invert · Play)** — 처음 · 끝 자리를 재서 사이를 움직이기
- **Rect interpolation** — 사각형 자리 · 크기 · 둥근 모서리 lerp

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 누른 카드의 자리 · 크기 · 모서리가 화면 전체까지 이어 커진다 (갑자기 다른 상자가 나타나지 않게)
- 다른 카드 · 제목은 동시에 옅어진다, 다음 화면 내용은 열림 60% 뒤에
- 닫을 때는 같은 값을 거꾸로 — 카드 자리로 돌아간다
- 열기 0.6초 안, smoothstep 같은 부드러운 이징
- 바뀌는 부분만 움직인다 — 화면 전체를 다시 그리지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽(그냥 바뀜)은 누르면 화면이 툭 바뀌고, 오른쪽(이어짐)은 누른 빨간 카드가 그대로 커져 게임 화면이 된다
- 오른쪽에서 다른 카드는 옅어지며 살짝 작아진다
- 닫힐 때 게임 화면이 다시 그 카드 자리로 줄어든다
- 폰에서도 끊김 없이

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

## 원리
- 그냥 바뀜: 열림 값이 0.5 를 넘는 순간 메뉴를 숨기고 게임을 보인다 — 어디서 왔는지 끊긴다.
- 이어짐: 누른 카드의 사각형 [x, y, 폭, 높이] 를 열림 e(0 → 1, 0.6초, smoothstep)로 화면 전체 [0, 0, 100, 100] 까지 lerp 한다. 둥근 모서리 2.4 → 0, 카드 아이콘 크기 → 0.
- 다른 카드는 1 − e 로 옅어지며 85% 까지 살짝 작아지고, 제목도 옅어진다. 게임 화면은 열림이 60% 를 넘은 뒤에 나타난다.
- 닫을 때는 같은 값을 거꾸로(1 → 0) 돌리면 카드로 돌아간다. 브라우저의 View Transitions API 도 같은 생각(이전 · 다음 화면을 찍어 그 사이를 이어 줌)이지만, 견본은 직접 사각형을 lerp 한다.

## 핵심 코드 — 카드 → 화면 이어 키우기 (open = 0 → 1)
(발췌: demos/demosSim.ts i23 update() 를 정리)
```ts
type Rect = [number, number, number, number]; // left · top · width · height (%)
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const ease = (k: number) => (k <= 0 ? 0 : k >= 1 ? 1 : k * k * (3 - 2 * k));
const slot = (i: number): Rect => [8 + (i % 2) * 44, 18 + Math.floor(i / 2) * 40, 40, 34];
const FULL: Rect = [0, 0, 100, 100];

function place(el: HTMLElement, r: Rect, rad: number): void {
  el.style.left = r[0] + '%';
  el.style.top = r[1] + '%';
  el.style.width = r[2] + '%';
  el.style.height = r[3] + '%';
  el.style.borderRadius = rad + 'cqh';
}

// open: 0 메뉴 · 1 게임 (열 때 0 → 1, 닫을 때 1 → 0, 각 0.6초)
function transition(open: number, picked: number, tiles: HTMLElement[], head: HTMLElement, game: HTMLElement): void {
  const e = ease(open);
  tiles.forEach((el, i) => {
    if (i === picked) {
      const s = slot(i);
      place(el, [lerp(s[0], FULL[0], e), lerp(s[1], FULL[1], e), lerp(s[2], FULL[2], e), lerp(s[3], FULL[3], e)], lerp(2.4, 0, e));
      el.style.fontSize = lerp(9, 0, e) + 'cqh'; // 아이콘은 사라짐
    } else {
      place(el, slot(i), 2.4);
      el.style.opacity = String(1 - e);
      el.style.transform = 'scale(' + (1 - e * 0.15) + ')';
    }
  });
  head.style.opacity = String(1 - e);
  game.style.opacity = String(Math.min(1, Math.max(0, (open - 0.6) / 0.4))); // 내용은 60% 뒤
}
```

## 흔한 실수 · 확인 목록
- [ ] **화면 전환 때 화면 전체를 새로 그리면 새로고침처럼 보인다** — 바뀌는 요소만 움직이고 갈아 끼운다 — 이 사이트에서 쪽 넘김 · 탭이 새로고침 같다고 지적받았다.
- [ ] **다음 화면 내용을 처음부터 보이면 커지는 카드 위에 글자가 겹쳐 지저분하다** — 열림 60% 뒤부터 나타나게.
- [ ] **누른 카드가 다른 카드 아래에 깔려 커진다** — 누른 카드의 z-index 를 다른 카드보다 높게 (견본은 첫 카드 2, 나머지 1).

## 완성 기준 체크리스트
- [ ] 왼쪽(그냥 바뀜)은 누르면 화면이 툭 바뀌고, 오른쪽(이어짐)은 누른 빨간 카드가 그대로 커져 게임 화면이 된다
- [ ] 오른쪽에서 다른 카드는 옅어지며 살짝 작아진다
- [ ] 닫힐 때 게임 화면이 다시 그 카드 자리로 줄어든다
- [ ] 폰에서도 끊김 없이

## 이 기술 정보
- id: `i23` · 분류: 이펙트 · 연출 › 입자 · 연출 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — left · top · width · height 를 매 프레임 바꾸면 레이아웃 계산이 붙는다. 카드 몇 장은 괜찮지만 무거운 화면이면 transform(scale · translate)으로 바꾼다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i23
- 쓰면 좋을 때: 메뉴 → 게임, 목록 → 자세히처럼 「이걸 눌러서 여기 왔다」를 보여 줄 때 / 화면 전환이 툭 바뀌어 새로고침처럼 느껴질 때
- 쓰지 말 때: 아주 자주 오가는 탭 — 0.6초 연출이 답답하다. 바뀌는 부분만 갈아 끼운다 / 카드와 다음 화면 모양이 전혀 달라 이어 보일 게 없을 때 — 옅게 겹치기(크로스페이드)로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i23 견본 항목 — `src/demos/demosSim.ts:3444`
```ts
  i23: {
    kind: 'dom',
    caption: '왼쪽: 화면이 툭 바뀜 · 오른쪽: 누른 카드가 그대로 커져 게임 화면이 됨 (View Transitions 의 「이어지는」 느낌)',
    make(box) {
      const wrap = document.createElement('div');
      wrap.style.cssText = `position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#fff;container-type:size;font-family:${FONT}`;
      const TILES = [
        ['#ff6a7a', '⚾'],
        ['#5ab8ff', '❄'],
        ['#7ae08a', '♞'],
        ['#ffd23a', '★'],
      ];
      const mkPane = (title: string, accent: string): { pane: HTMLElement; tiles: HTMLElement[]; game: HTMLElement; cursor: HTMLElement; head: HTMLElement } => {
        const pane = document.createElement('div');
        pane.style.cssText = 'position:relative;overflow:hidden;background:linear-gradient(#eef4ff,#d8e6ff)';
        const head = document.createElement('div');
        head.style.cssText = `position:absolute;left:6%;top:4%;font:800 6cqh/1 ${FONT};color:#30406a`;
        head.textContent = '게임 고르기';
        const tag = document.createElement('div');
        tag.style.cssText = `position:absolute;right:4%;top:3%;z-index:5;padding:1cqh 2.2cqw;border-radius:99px;background:${accent};color:#fff;font:800 4.6cqh/1.3 ${FONT}`;
        tag.textContent = title;
        pane.append(head, tag);
        const tiles = TILES.map(([c, icon], i) => {
          const el = document.createElement('div');
          el.style.cssText = `position:absolute;border-radius:2.4cqh;background:${c};display:grid;place-items:center;font-size:9cqh;color:#fff;box-shadow:0 0.8cqh 1.6cqh rgba(40,60,120,.25);z-index:${i === 0 ? 2 : 1}`;
          el.textContent = icon!;
          pane.appendChild(el);
          return el;
        });
        const game = document.createElement('div');
        game.style.cssText = 'position:absolute;inset:0;z-index:3;opacity:0;pointer-events:none';
        game.innerHTML = `<div style="position:absolute;left:6%;top:16%;font:800 5cqh/1 ${FONT_CSS};color:#fff">숫자 야구</div><div style="position:absolute;left:50%;top:58%;transform:translate(-50%,-50%);display:flex;gap:2cqw">${[3, 7, 1].map((n) => `<div style="width:9cqw;height:9cqw;border-radius:50%;background:#fff;display:grid;place-items:center;font:900 5cqw/1 ${FONT_CSS};color:#c02a3a;box-shadow:0 .6cqh 0 rgba(0,0,0,.25)">${n}</div>`).join('')}</div><div style="position:absolute;left:50%;bottom:8%;transform:translateX(-50%);padding:1.2cqh 4cqw;border-radius:99px;background:#ffd23a;font:800 5cqh/1 ${FONT_CSS};color:#5a2a00">던지기</div>`;
        pane.appendChild(game);
        const cursor = document.createElement('div');
        cursor.style.cssText = 'position:absolute;z-index:6;width:5cqh;height:5cqh;border-radius:50%;background:rgba(255,255,255,.85);border:0.5cqh solid #30406a;transform:translate(-50%,-50%);pointer-events:none';
        pane.appendChild(cursor);
        return { pane, tiles, game, cursor, head };
      };
      const A = mkPane('그냥 바뀜', '#8a94b0');
      const B = mkPane('부드럽게 이어짐', '#ff8a3a');
      wrap.append(A.pane, B.pane);
      box.appendChild(wrap);
      // 카드 자리 (%)
      const slot = (i: number): [number, number, number, number] => [8 + (i % 2) * 44, 18 + Math.floor(i / 2) * 40, 40, 34];
      const full: [number, number, number, number] = [0, 0, 100, 100];
      const place = (el: HTMLElement, r: [number, number, number, number], rad: number): void => {
        el.style.left = `${r[0]}%`;
        el.style.top = `${r[1]}%`;
        el.style.width = `${r[2]}%`;
        el.style.height = `${r[3]}%`;
        el.style.borderRadius = `${rad}cqh`;
      };
      const LOOP = 5;
      return {
        update(t) {
          const k = t % LOOP;
          // 0~1.2 메뉴 · 1.2 누름 · 1.2~1.8 열림 · 1.8~3.6 게임 · 3.6~4.2 닫힘 · 4.2~5 메뉴
          const open = k < 1.2 ? 0 : k < 1.8 ? (k - 1.2) / 0.6 : k < 3.6 ? 1 : k < 4.2 ? 1 - (k - 3.6) / 0.6 : 0;
          const curK = clamp(k / 1.1, 0, 1);
          for (const P of [A, B]) {
            const cx = lerp(80, 28, ease(curK));
            const cy = lerp(85, 35, ease(curK));
            P.cursor.style.left = `${cx}%`;
            P.cursor.style.top = `${cy}%`;
            const press = k > 1.0 && k < 1.25;
            P.cursor.style.transform = `translate(-50%,-50%) scale(${press ? 0.7 : 1})`;
            P.cursor.style.opacity = open > 0.02 && open < 0.98 ? '0' : open >= 0.98 ? '0' : '1';
          }
          // 왼쪽 — 툭
          const hardOpen = open > 0.5 ? 1 : 0;
          A.tiles.forEach((el, i) => {
            place(el, slot(i), 2.4);
            el.style.opacity = hardOpen ? '0' : '1';
          });
          A.head.style.opacity = hardOpen ? '0' : '1';
          A.game.style.opacity = String(hardOpen);
          A.game.style.background = TILES[0]![0]!;
          // 오른쪽 — 이어짐
          const e = ease(open);
          B.tiles.forEach((el, i) => {
            if (i === 0) {
              const s = slot(0);
              place(el, [lerp(s[0], full[0], e), lerp(s[1], full[1], e), lerp(s[2], full[2], e), lerp(s[3], full[3], e)], lerp(2.4, 0, e));
              el.style.fontSize = `${lerp(9, 0, e)}cqh`;
            } else {
              place(el, slot(i), 2.4);
              el.style.opacity = String(1 - e);
              el.style.transform = `scale(${1 - e * 0.15})`;
            }
          });
          B.head.style.opacity = String(1 - e);
          B.game.style.opacity = String(clamp((open - 0.6) / 0.4, 0, 1));
        },
        dispose() {
          wrap.remove();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [장면 전환 카메라 (궤도 이동)](https://ai-techstudio.web.app/ai/t/i430.md) `i430`
- 참고 문서: [MDN — View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
