글 목록의 카드를 클릭하면 카드의 제목이 그대로 상세 페이지의 제목 위치로 미끄러져 갑니다. 네이티브 앱에서는 당연한 이 움직임을 이 사이트에도 넣었습니다. 구현 자체는 수십 줄로 끝났지만, "움직이지 않는 이유가 에러에도 경고에도 나오지 않는" 상황을 여러 번 만났고, 원인을 찾는 데 대부분의 시간을 썼습니다.
이 글에서는 Next.js 16(App Router)과 React의 <ViewTransition>으로 공유 요소 트랜지션을 만든 과정을 실제로 측정한 수치와 함께 남깁니다. 같은 것을 하려는 분이 같은 함정에 빠지지 않기를 바랍니다.
전제와 하고 싶었던 것#
환경#
| 항목 | 버전·설정 |
|---|---|
| Next.js | 16.3 (App Router · 모든 라우트 prerender) |
| React | 19.2 (App Router는 Next.js에 포함된 React로 동작) |
| 스타일링 | Tailwind CSS v4 + CSS 커스텀 프로퍼티 디자인 토큰 |
| 측정 | Playwright(Chromium)로 getBoundingClientRect() 측정 |
목표#
목록 페이지의 글 카드에는 카테고리 배지·제목·태그가 있습니다. 상세 페이지에도 같은 세 가지가 있습니다. 이 셋을 "같은 것"으로 브라우저에 알려 페이지가 바뀌는 순간 위치와 크기를 보간하게 하는 것이 출발점이었습니다.
- 카드의 제목이 상세 페이지의
h1로 이동·확대된다 - 카테고리와 태그 배지도 각각 대응하는 위치로 이동한다
- 그 밖의 부분은 페이지 전체 크로스페이드로 바뀐다
prefers-reduced-motion이 켜져 있으면 전혀 움직이지 않는다
구조를 간단히#
View Transitions API는 DOM을 바꾸기 전후의 스냅숏을 찍고, 두 장을 의사 요소 트리(::view-transition-old / ::view-transition-new)로 겹쳐 보간합니다. 같은 view-transition-name을 가진 요소는 old와 new가 하나의 ::view-transition-group으로 묶이고, 사각형의 위치와 크기가 애니메이션됩니다.
React 19의 <ViewTransition>은 이 이름 붙이기와 document.startViewTransition() 호출을 React의 전환(startTransition)에 연결해 줍니다. Next.js App Router는 라우터 전환을 startTransition으로 감싸고 있으므로, 래퍼를 두기만 하면 페이지 전환이 이 API를 탑니다.
import { ViewTransition } from "react";
export default function LocaleLayout({ children }: { children: React.ReactNode }) {
return (
<main>
{/* 이름 없는 래퍼는 모든 페이지 공통의 크로스페이드가 된다 */}
<ViewTransition>{children}</ViewTransition>
</main>
);
}공유 요소로 만들 쪽은 목록과 상세 양쪽에 같은 이름을 붙입니다.
export function PostCard({ post }: { post: Post }) {
return (
<ViewTransition name={`post-title-${post.slug}`}>
<h2 className="w-fit font-bold text-xl">{post.title}</h2>
</ViewTransition>
);
}여기까지는 공식 문서 그대로입니다. 문제는 여기서부터였습니다.
함정 1: 이름이 충돌해도 아무 말이 없다#
태그 이름을 단순히 post-tag-${tag}로 하면, 같은 태그를 가진 글이 목록에 두 개 나란히 놓이는 순간 한 장의 스냅숏 안에 같은 이름이 두 개 생깁니다. view-transition-name은 스냅숏 안에서 유일해야 하고, 겹치면 전환이 건너뛰어집니다.
곤란한 점은 그렇게 되어도 화면은 평범한 전환으로 돌아갈 뿐이고, 이 사이트에서 시험한 범위에서는 React의 경고도 없었다는 것입니다. "왠지 움직이지 않는다"는 증상만 남습니다.
해결은 간단합니다. 이름을 소유 엔티티의 slug로 스코프합니다.
- <ViewTransition name={`post-tag-${tag}`}>
+ <ViewTransition name={`post-tag-${post.slug}-${tag}`}>그 뒤로 이 사이트에서는 명명 규칙을 {domain}-{element}-{slug}[-{subkey}]로 고정하고 디자인 시스템 문서에 적어 두었습니다. 규칙으로 정해 두지 않으면, 다음에 누군가(미래의 나)가 새 공유 요소를 추가할 때 같은 함정을 밟습니다.
함정 2: 텍스트의 "박스"가 보간된다#
이름을 붙이면 제목이 움직이지만, 이동 중인 제목이 번진 이중상이 될 때가 있습니다. 짧은 제목이 옆으로 늘어났다가 줄어드는 듯한 움직임입니다.
원인은 보간 대상이 글자의 보이는 폭이 아니라 요소 박스의 사각형이었다는 것입니다. 제목은 블록 요소라서 텍스트가 짧아도 부모의 폭 전체를 차지합니다.
위 두 이미지의 제목(일본어 글 "riku-mono.me를 공개했습니다")의 박스 폭을 화면 폭 1280px에서 측정한 값입니다.
| 경우 | 목록의 박스 폭 | 상세의 박스 폭 |
|---|---|---|
w-fit 없음 (block 그대로) | 546px | 984px |
양쪽에 w-fit | 320px | 528px |
w-fit이 없으면 보간되는 것은 546px → 984px의 사각형이라, 글자 크기 변화와 상관없이 옆으로 크게 늘어납니다. 왼쪽 정렬 텍스트라면 w-fit을 붙여도 그려지는 위치는 바뀌지 않습니다. 보이는 모습은 전혀 바꾸지 않고, 보간의 기준이 되는 사각형만 텍스트에 밀착시킬 수 있습니다.
배지(inline-flex)는 원래 박스가 텍스트에 밀착해 있어서 아무것도 하지 않아도 깔끔하게 움직였습니다. 이미지도 aspect-video 등으로 비율을 맞춰 두면 문제없습니다.
함정 3: 스크롤해 있으면 카테고리만 움직이지 않는다#
가장 시간이 걸린 것이 이것입니다. 목록을 조금 아래로 스크롤한 뒤 카드를 열면 카테고리 배지만 morph하지 않고 페이드로 바뀌었습니다. 제목과 태그는 움직이는데 말입니다.
무슨 일이 일어났나#
React는 전환이 시작된 시점에 뷰포트 안에 있는 요소에만 view-transition-name을 붙입니다. 게다가 전환될 페이지의 DOM은 "전환 전 페이지의 스크롤 위치 그대로" 측정됩니다. 당시 상세 페이지에서는 문서상의 위치가 위에서부터 카테고리·제목·태그 순이었습니다.
- 카테고리: 164~198px
- 제목: 210~285px
- 태그: 329~355px
스크롤이 깊을수록 위에 있는 것부터 차례로 뷰포트 밖으로 떨어집니다. 카테고리는 상세 페이지에서 가장 위에 있는 공유 요소라서 가장 먼저 대상에서 빠진 것입니다. "카테고리만 불안정"해 보였던 것은 요소의 종류 때문이 아니라 단지 위치의 순서 때문이었습니다.
| 클릭 시 scrollY | 카테고리 | 제목 | 태그 |
|---|---|---|---|
| 150 | ✅ | ✅ | ✅ |
| 233 | ❌ | ✅ | ✅ |
| 300 | ❌ | ❌ | ✅ |
| 450 | ❌ | ❌ | ❌ |
해결: 전환 전에 스크롤을 되돌린다#
링크 클릭을 capture 단계에서 받아, 전환이 시작되기 전에 스크롤을 맨 위로 되돌리는 컴포넌트를 두었습니다. capture 단계인 것은 Next.js의 <Link>가 전환을 시작하기 전에 끼어들기 위해서입니다. 다만 여러 화면 아래에서 한 번에 되돌리면 "클릭한 곳과 무관한 위치에서 날아오는" 움직임이 되므로, 한 화면 이내일 때만 되돌리고 그보다 깊으면 아무것도 하지 않고 페이지 전체 크로스페이드에 맡깁니다.
"use client";
import { useEffect } from "react";
export function ScrollResetBeforeNavigation() {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest?.("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
// 같은 페이지 안의 앵커(목차 등)는 스크롤을 빼앗지 않는다
if (url.pathname === window.location.pathname) return;
// 한 화면보다 깊은 곳에서 되돌리면 출발점이 엉뚱한 morph가 된다
if (window.scrollY === 0 || window.scrollY > window.innerHeight) return;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
};
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
}, []);
return null;
}scrollTo(0, 0)이 효과가 없었다#
처음 구현은 behavior: 'instant'가 아니라, 직전에 scrollBehavior를 바꾼 뒤 scrollTo(0, 0)을 호출하는 형태였습니다. <html>에 scroll-smooth가 붙어 있어서 그대로 두면 애니메이션이 되기 때문입니다.
그런데 이것은 전혀 효과가 없었습니다. scrollTo는 계산된 스타일을 읽을 뿐, 직전에 쓴 스타일 변경을 반영하지 않습니다. React가 전환을 시작하는 것은 클릭 후 약 1ms 뒤라서, 그 시점에 스크롤은 1px도 움직이지 않았던 것입니다. 같은 페이지에서 scrollY = 300부터 시험한 직후의 값입니다.
| 작성 방법 | 직후의 scrollY |
|---|---|
style.scrollBehavior = 'auto' → scrollTo(0, 0) | 300 |
위를 !important로 | 300 |
documentElement.scrollTop = 0 | 300 |
스타일 변경 → 강제 레이아웃 → scrollTo(0, 0) | 0 |
scrollTo({ top: 0, behavior: 'instant' }) | 0 |
ScrollToOptions의 behavior는 CSS의 scroll-behavior보다 우선하므로 스타일 반영 순서에 의존하지 않습니다. 강제 레이아웃을 끼우는 방법보다 부작용이 없어서 이쪽을 택했습니다1.
모션 값을 디자인 토큰에 맞추기#
::view-transition-*은 일반 요소 트리 밖에 있는 의사 요소라서 Tailwind 유틸리티가 적용되지 않습니다. 아무것도 하지 않으면 UA 기본값인 250ms·linear로 움직여, 사이트의 다른 애니메이션(200ms·자체 이징)과 속도도 질감도 맞지 않습니다.
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: var(--duration-normal);
}
/* 공유 요소의 이동은 "왕복하는 하나의 움직임" */
::view-transition-group(*) {
animation-timing-function: var(--ease-default);
}
/* old / new의 교체는 "크로스페이드" */
::view-transition-old(*),
::view-transition-new(*) {
animation-timing-function: var(--ease-in-out);
}React가 자동으로 붙이는 익명 이름(_t_0_ 등)은 안정적이지 않아서, 이름을 하나하나 지정하지 않고 *로 적용하고 있습니다.
reduced-motion 취소를 잊지 않기#
사이트 전체에서는 @media (prefers-reduced-motion: reduce) 안에서 *, ::before, ::after의 애니메이션을 멈추고 있지만, ::view-transition-*은 이 셀렉터에 매치되지 않습니다. 별도의 의사 요소 트리에 속하기 때문입니다. 같은 미디어 쿼리 안에서 따로 꺼야 합니다.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}아직 풀지 못한 것#
- 브라우저 뒤로 가기에서는 움직이지 않는다. popstate로 인한 내비게이션에서는
startViewTransition이 호출되지 않습니다. React / Next.js 쪽의 제약이라, 지금은 상세 페이지에 브레드크럼을 두고<Link>로 돌아가는 동선을 마련해 우회하고 있습니다. - 날짜는 공유 요소로 만들지 않았다. 목록과 상세에서 날짜의 모양이 달라 morph시키면 형태가 무너집니다. 모양을 맞출지 먼저 정해야 합니다.
- 관련 글 카드에는 이름을 붙이지 않는다. 글 A에서 B로 이동했을 때 B의 관련 글에 A의 카드가 있으면, A의 제목이 페이지 아래쪽 카드로 날아가 버립니다.
정리#
공유 요소 트랜지션은 잘 움직일 때는 마법처럼 보이지만, 망가졌을 때는 아무 말 없이 평범한 페이드로 돌아갈 뿐입니다. 그래서 다음 세 가지를 규칙으로 먼저 정해 둔 것이 효과가 있었습니다.
- 이름은 반드시 소유 엔티티의 slug로 스코프한다
- 공유 요소로 쓰는 텍스트는 양쪽 박스를 텍스트에 밀착시킨다(
w-fit) - 전환 전에, 한 화면 이내라면 스크롤을 맨 위로 되돌린다
측정에는 Playwright를 썼는데, locator.click()은 내부에서 스크롤을 움직이므로 스크롤 위치를 유지한 채 클릭하려면 page.mouse.click(x, y)를 써야 합니다. 이를 깨닫기 전까지는 scrollY를 300으로 하든 450으로 하든 측정할 때마다 233이 되는 수수께끼에도 시달렸습니다(html의 scroll-padding-top만큼 카드의 위쪽 끝에 맞춰 스크롤되고 있었습니다).
각주
-
강제 레이아웃(
getBoundingClientRect()같은 읽기)을 끼우면scrollBehavior변경도 반영되지만, 레이아웃 계산이 한 번 더 일어나고 "왜 여기서 읽는지"를 주석으로 계속 설명해야 합니다. ↩