flemo

LayoutScreenExperimental

두 화면을 잇는 공유 요소 모핑 — 목록의 썸네일이 상세의 큰 이미지로 펼쳐지는 그 동작

<LayoutScreen>은 화면 사이에 공유 요소 모핑(shared-element morph) 을 더한 <Screen>의 대체품이에요. 목록의 작은 썸네일이 다음 화면의 큰 이미지로 자연스럽게 펼쳐졌다가, 뒤로 가면 다시 접히는 동작. iOS의 사진/뮤직 앱이 쓰는 바로 그 패턴이고, Material 3에선 container transform이라고 불러요.

@flemo/react-layout이라는 별도 패키지에 들어 있어요 — @flemo/react와는 분리되어 있어요. motion이 peer dependency이기 때문에, 모핑이 필요 없는 앱은 motion을 함께 포함하지 않아도 돼요.

언제 쓰면 좋을까

상황선택
카드 사이에 시각적인 연속성이 없는 평범한 목록 → 상세<Screen>
썸네일/카드가 다음 화면의 본문으로 시각적으로 이어져야 할 때<LayoutScreen>
상단 hero 이미지가 화면 전환 중에도 자연스럽게 커져야 할 때<LayoutScreen>
두 화면이 전환 중에 한 덩어리의 UI처럼 보여야 할 때<LayoutScreen>

출발 화면과 도착 화면 사이에 공유되는 요소가 없다면 사용하지 않아도 돼요. 기본 cupertinomaterial 트랜지션을 사용하는 <Screen>이 더 가볍고 단순해요.

설치

pnpm add @flemo/react-layout motion

motion은 peer dependency예요. 앱 다른 곳에서 이미 motion을 사용하고 있다면 별도로 설치하지 않아도 돼요.

마음속 모델

모프 한 번은 네 조각이 함께 동작해요.

조각어디에역할
transitionName: "layout"navigate.push 옵션모프가 보이도록 화면을 살짝만 페이드
layoutId (push 옵션)navigate.push 옵션짝짓기 키를 도착 화면에 전달
<LayoutConfig>양쪽 화면의 모프 트리 바깥motion의 layout 타이밍을 화면 전환과 동기화
<LayoutScreen>도착 화면 (<Screen> 대신)unmount 사이에도 layoutId 짝짓기를 유지
motion.* + layoutId모프되어야 하는 모든 요소 (이미지·제목·컨테이너 등)두 화면의 DOM 노드가 "같은 것"임을 motion에 알려줌

짝짓기 규칙은 단순해요 — 출발 화면과 도착 화면의 layoutId가 한 글자도 다르지 않아야 해요. 다르면 motion이 짝을 찾지 못해 모프가 조용히 실패해요 (페이드만 일어나요).

<Screen>이 아니라 <LayoutScreen>이 필요할까

<Screen>은 화면을 떠날 때 트리를 즉시 unmount해요. 평범한 push에선 그게 자연스러워요 — 이전 화면은 사라지고 새 화면이 새로 그려지면 끝이에요. 하지만 모프는 motion이 전환 시점에 두 화면의 같은 layoutId를 동시에 볼 수 있어야 동작해요. <LayoutScreen>은 트리를 한 박자 더 살려 두고 (AnimatePresence로 감싸서) 도착 화면의 노드를 출발 화면의 노드와 짝지을 시간을 줘요.

추가로 배경을 transparent로 칠하기 때문에, 모프 중인 요소가 단색에 가려지는 일이 없어요.

<LayoutConfig>가 필요할까

motion의 <motion.div layout>은 자체 기본 spring으로 움직여요 (stiffness ~700, damping 30). flemo의 화면 전환은 다른 타이밍(layout 프리셋: 0.3초 ease)으로 돌아가요. 둘을 맞춰주지 않으면 화면 페이드는 끝났는데 모프는 아직 늘어나는 중이거나, 그 반대 상황이 벌어져요 — 모프가 반쯤 페이드된 배경 위에 안착하는 식의 어색한 그림이 나와요.

<LayoutConfig>는 현재 navigate.push가 쓰는 트랜지션의 duration·ease를 읽어 motion의 MotionConfig에 넘겨줘요. 화면 페이드와 모프가 같은 프레임에 끝나요.

모프되어야 할 트리의 가장 바깥에 둬요 — 보통 <LayoutScreen> 바로 안쪽이에요.

transitionName: "layout"이 필요할까

기본 cupertino 트랜지션은 새 화면 전체를 오른쪽에서 슬라이드해 들여요. 이 슬라이드가 모프를 가려 버려요 — 새 화면이 자리를 잡았을 땐 모프가 도착할 곳이 이미 없어요.

layout 프리셋은 화면 자체에 짧고 옅은 opacity ramp(0.97 → 1, 0.3초)만 걸고 평행이동은 하지 않아요. motion이 모프를 그릴 길을 비켜 주는 거예요.

이 자리에 커스텀 트랜지션을 만들어 끼울 수도 있어요 — 조건은 "출발 요소를 가리는 평행이동이 없을 것" 하나예요. 보통은 opacity나 filter만 살짝 다루는 게 안전해요.

전체 예시

Gallery.tsx — 출발 화면
import { Screen, useNavigate } from "@flemo/react";
import { LayoutConfig } from "@flemo/react-layout";
import { motion } from "motion/react";

function Gallery() {
  const navigate = useNavigate();
  return (
    <Screen>
      <LayoutConfig>
        <ul>
          {photos.map((p) => (
            <motion.li
              key={p.id}
              layoutId={`photo-card-${p.id}`}
              onClick={() =>
                navigate.push(
                  "/photos/:id",
                  { id: p.id },
                  { transitionName: "layout", layoutId: p.id }
                )
              }
            >
              <motion.img layoutId={`photo-image-${p.id}`} src={p.thumb} />
              <motion.span layoutId={`photo-title-${p.id}`}>{p.title}</motion.span>
            </motion.li>
          ))}
        </ul>
      </LayoutConfig>
    </Screen>
  );
}
Photo.tsx — 도착 화면
import { useScreen } from "@flemo/react";
import { LayoutScreen, LayoutConfig } from "@flemo/react-layout";
import { motion } from "motion/react";

function Photo() {
  const { layoutId } = useScreen();
  const photo = usePhoto(layoutId);

  return (
    <LayoutScreen>
      <LayoutConfig>
        <motion.div layoutId={`photo-card-${layoutId}`} className="fixed inset-0">
          <motion.img layoutId={`photo-image-${layoutId}`} src={photo.full} />
          <motion.h1 layoutId={`photo-title-${layoutId}`}>{photo.title}</motion.h1>
          <p>{photo.caption}</p>
        </motion.div>
      </LayoutConfig>
    </LayoutScreen>
  );
}

체크 포인트:

  • 양쪽 화면 모두 motion 트리를 <LayoutConfig>로 감싸요. 출발 화면 쪽 카드도 같은 타이밍으로 움직여야 해서요.
  • 묶일 요소들은 같은 prefix의 layoutId를 가져요. photo-card-, photo-image-, photo-title- 각각에 같은 id를 붙여서 — 한 덩어리처럼 함께 모프해요.
  • 도착 화면은 useScreen().layoutId로 키 문자열을 다시 만들어요. 출발에서 push할 때 넘긴 layoutId가 이 채널로만 도착하기 때문에, 도착 화면이 "어느 카드에서 왔는지"를 알 수 있는 유일한 길이에요.
  • 도착 화면의 모프 컨테이너는 fixed inset-0이에요. 의도된 거예요 — 컨테이너가 뷰포트 전체를 채우도록 모프하면서 "펼쳐지는" 느낌이 만들어져요.

패턴

목록 → 상세 (위 예시)

가장 깔끔한 케이스. 썸네일 그리드의 각 셀이 풀스크린 상세로 펼쳐져요.

카드 → 모달 형태의 오버레이

같은 골격이지만 도착 화면이 뷰포트 전체를 채우지는 않고, 카드 크기 정도의 시트로 모프하는 패턴. 도착 화면의 배경을 transparent로 둬서 (<LayoutScreen> 기본값) 뒤의 출발 화면이 계속 보이게 해요.

Hero 공유 요소 (이미지만 따라감)

이미지에만 layoutId를 붙이고 주변 chrome은 그대로 두는 방식. 이미지가 한 위치에서 다른 위치로 떠다니듯 이동하면서, 나머지 화면 영역은 평범한 전환으로 처리돼요. 헤더 이미지가 상세 페이지로 "올라타는" 느낌을 만들 때 잘 어울려요.

자주 만나는 함정

"모프 타이밍이 이상해요 — 고무처럼 튕기거나 어긋나요"

한쪽 화면에서 <LayoutConfig>를 빼먹은 상태예요. motion이 자기 기본 spring으로 움직이고 있어요. 양쪽 화면 모두에 <LayoutConfig>를 추가하세요.

"새 화면이 슬라이드해 들어올 뿐 모프가 안 보여요"

navigate.push 호출에 transitionName: "layout"이 빠졌어요. 기본 cupertino 슬라이드가 모프를 가려요.

navigate.push("/photos/:id", { id: p.id }, { transitionName: "layout", layoutId: p.id });

"모프가 일어나지 않고 페이드만 돼요"

두 화면의 layoutId 문자열이 다른 거예요. 양쪽 다 찍어 보세요.

// 출발
console.log("source", `photo-image-${p.id}`);
// 도착
console.log("dest", `photo-image-${layoutId}`);

자주 보이는 원인:

  • 출발은 p.id(숫자)를 그대로 쓰는데 도착은 문자열로 캐스팅 (혹은 그 반대)
  • 출발이 layoutId: p.id로 push를 안 했고, 도착에서 useScreen().layoutIdundefined
  • prefix 오타 (photo-image- vs photo-img-)

"바깥 컨테이너만 모프되고 안쪽 이미지·제목은 어색하게 페이드인돼요"

부모 한 노드에만 layoutId를 붙인 상태예요. 같이 움직여야 하는 요소는 모두 묶어주세요.

<motion.div layoutId={`card-${id}`}>
  <motion.img layoutId={`image-${id}`} /> {/* 이미지도 */}
  <motion.h1 layoutId={`title-${id}`} /> {/* 제목도 */}
</motion.div>

"앞으로 push는 잘 되는데 뒤로 가기에서 깨져요"

도착 화면을 <Screen>으로 두셨네요. pop할 때 도착 화면이 unmount되는 순간 motion의 역방향 모프가 짤려서 짝짓기가 깨져요. <LayoutScreen>으로 바꿔 주세요.

"모프 중에 화면이 멈칫하거나 재렌더되는 게 보여요"

모프 트리 안에 무거운 컴포넌트를 두지 마세요. 모프 중에는 도착 화면이 마운트된 채 출발 화면도 같이 보이는 상태 — 도착 쪽의 무거운 작업이 motion 레이아웃 엔진의 프레임을 가져가요. 상세 본문은 Suspense로 lazy-load해서 모프는 shell만으로 돌고 나머지 콘텐츠는 스트리밍되도록 해 주세요.

사용하지 않아도 되는 경우

  • 도착 화면이 출발 요소를 시각적으로 이어받지 않을 때 — 일반 <Screen>과 기본 트랜지션 이 정답이에요.
  • 단순한 페이지 슬라이드만 필요할 때 — cupertinomaterial로 충분해요.
  • 번들 크기를 최소화해야 하고 모프가 필요 없을 때 — @flemo/react-layout을 설치하지 마세요 (motion이 함께 포함돼요).

Props

<LayoutScreen><Screen>과 같은 prop을 받아요 — 앱 바, 네비게이션 바, 안전 영역, 배경, 스크롤 모두 동일해요. 전체 표는 Screen 페이지에서 보실 수 있어요. 차이는 단 하나: 기본 배경이 white가 아니라 transparent라서, 모프 중인 요소가 가려지지 않아요.

<LayoutConfig>는 motion의 MotionConfig와 같은 prop을 받아요. transition 필드는 flemo가 활성 트랜지션의 값으로 자동으로 채우니, 명시적으로 덮어 쓰고 싶을 때만 직접 넘기시면 돼요.

이 페이지에서