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> |
출발 화면과 도착 화면 사이에 공유되는 요소가 없다면 사용하지 않아도 돼요. 기본 cupertino나
material 트랜지션을 사용하는 <Screen>이 더 가볍고 단순해요.
설치
pnpm add @flemo/react-layout motionmotion은 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만 살짝 다루는 게 안전해요.
전체 예시
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>
);
}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().layoutId가undefined - prefix 오타 (
photo-image-vsphoto-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>과 기본 트랜지션 이 정답이에요. - 단순한 페이지 슬라이드만 필요할 때 —
cupertino나material로 충분해요. - 번들 크기를 최소화해야 하고 모프가 필요 없을 때 —
@flemo/react-layout을 설치하지 마세요 (motion이 함께 포함돼요).
Props
<LayoutScreen>은 <Screen>과 같은 prop을 받아요 — 앱 바, 네비게이션 바, 안전 영역,
배경, 스크롤 모두 동일해요. 전체 표는 Screen 페이지에서 보실 수 있어요.
차이는 단 하나: 기본 배경이 white가 아니라 transparent라서, 모프 중인 요소가 가려지지
않아요.
<LayoutConfig>는 motion의 MotionConfig와 같은 prop을 받아요. transition 필드는
flemo가 활성 트랜지션의 값으로 자동으로 채우니, 명시적으로 덮어 쓰고 싶을 때만 직접
넘기시면 돼요.