Next.js View Transition을 적용했다가 걷어낸 이유
App Router에 페이지 전환을 넣으려고 Next 16의 ViewTransition을 켰다가 다시 뺐습니다. 스냅샷이 뷰포트가 아니라 요소 전체 높이로 잡히고, 전환 중에는 스크롤이 막힙니다. 결국 key 재마운트와 CSS 애니메이션으로 돌아왔습니다.
geonoooo
2 min read
블로그 페이지를 옮길 때 화면이 부드럽게 바뀌면 좋겠다고 생각했습니다. 기준으로 삼은 건 Claude 블로그입니다.
목표가 "저 사이트처럼"이면 먼저 저 사이트가 뭘 쓰는지 확인해야 합니다. 안 그러면 감으로 숫자를 바꾸며 계속 헛돕니다. 실제로 그렇게 두 번 헛돌았습니다.
NOTE
측정 환경 Next 16.2.4 (App Router), React 19.2.4, Tailwind v4. Playwright 1.63 + Chromium으로 측정했습니다. 대상 사이트 측정은 2026년 9월 기준입니다.
1단계 — 따라 하려는 대상부터 측정한다
Playwright로 Claude 블로그를 열고, 링크를 클릭한 뒤 무슨 일이 일어나는지 기록했습니다.
await page.addInitScript(() => {
window.__vt = 0;
const orig = document.startViewTransition?.bind(document);
if (orig) {
document.startViewTransition = (...args) => {
window.__vt++;
return orig(...args);
};
}
});document.startViewTransition을 가로채두면 View Transitions API를 쓰는지 바로 드러납니다. 동시에 스타일시트를 전부 훑어 view-transition 관련 규칙이 있는지도 셌습니다.
결과입니다.
| 항목 | 결과 |
|---|---|
startViewTransition 호출 | 0회 |
@view-transition / ::view-transition-* 규칙 | 0개 (4371개 룰 스캔) |
| 내비게이션 | 풀 페이지 로드 (MPA) |
| 프레임워크 | Webflow (w-mod-js w-mod-ix) |
View Transitions API를 안 씁니다. React도 아닙니다.
대신 DOM에 이런 요소가 박혀 있었습니다.
<div aria-hidden="true" class="transition_wrap"
style="opacity: 0; visibility: hidden; display: none;"></div>position: fixed | z-index: 99999 | background: rgb(250,249,245) | 100% × 100vh배경색이 페이지 배경과 같은 크림색인 전체 화면 판입니다. 링크를 누르면 이 판을 덮고, 새 문서를 받고, 걷어냅니다. MPA는 문서가 파기되는 동안 JS가 죽어서 애니메이션을 걸 수 없으니, 그 구간을 같은 색으로 가리는 겁니다.
그리고 새 문서가 뜬 뒤 콘텐츠가 등장합니다. 제목은 <span class="word"> 단위로 쪼개져 순차로 나타납니다.
여기서 얻은 결론이 둘입니다.
- 오버레이는 MPA라서 쓴 우회책이다. App Router는 소프트 내비게이션이라 깜빡임 자체가 없으므로 따라 할 이유가 없다.
- 진짜 봐야 할 건 오버레이가 아니라 콘텐츠가 등장하는 애니메이션이다.
2단계 — 선택지를 늘어놓는다
App Router에서 페이지 전환을 넣는 방법은 크게 넷입니다.
방법 A. CSS만으로 하는 MPA View Transition
@view-transition {
navigation: auto;
}두 줄이면 끝납니다. 다만 이건 문서가 실제로 교체될 때만 동작합니다. App Router의 소프트 내비게이션은 문서를 유지한 채 DOM만 갈아끼우므로 이 규칙은 아무 일도 하지 않습니다. 후보에서 탈락입니다.
방법 B. React <ViewTransition> (Next 16)
Next 16이 정식으로 지원합니다.
// next.config.ts
experimental: {
viewTransition: true,
}<ViewTransition default="page">
<main>{children}</main>
</ViewTransition>라우팅이 이미 React Transition이라 별도 트리거 없이 붙습니다. 가장 "정석"으로 보이는 길입니다. 실제로 여기부터 시작했습니다.
방법 C. key 재마운트 + 일반 CSS 애니메이션
경로가 바뀌면 요소를 새로 만들고, CSS animation으로 등장시킵니다. 브라우저 API에 기대지 않습니다.
방법 D. 애니메이션 라이브러리
framer-motion 같은 것. 이 블로그는 의존성을 늘리지 않는 쪽이라 처음부터 제외했습니다. 페이지 전환 하나 때문에 런타임을 얹을 이유가 없습니다.
3단계 — 방법 B로 만들고, 네 번 부딪힌다
먼저 방법 B로 구현했습니다. main을 <ViewTransition default="page">로 감싸고, ::view-transition-old(.page) / new(.page)에 페이드를 걸었습니다. 측정상 startViewTransition이 호출되고 388ms에 완료됐습니다. 여기까지는 잘 됐습니다.
그런데 브라우저에서 눌러 보니 아무것도 안 보였습니다.
부딪힌 것 1 — 접근성 처리를 과하게 했다
제가 이렇게 써놨습니다.
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
}
}그리고 제 맥에는 "동작 줄이기"가 켜져 있었습니다.
$ defaults read com.apple.universalaccess reduceMotion
1코드는 정상이었고 설정대로 0초로 끝나고 있었습니다. Playwright의 reducedMotion 컨텍스트로 양쪽을 재보니 명확했습니다.
| 조건 | 전환 완료 |
|---|---|
| 동작 줄이기 끔 | 388ms |
| 동작 줄이기 켬 | 55ms |
prefers-reduced-motion은 "애니메이션을 끄라"가 아니라 위치 이동을 줄이라는 신호입니다. 제자리에서 일어나는 opacity 페이드는 화면을 가로지르는 움직임이 없어 해당 사항이 아닙니다. 전부 죽인 게 잘못이었습니다.
고친 방식은 animation-name만 갈아끼우는 것입니다. duration과 easing은 그대로 두고 키프레임만 바꿉니다.
@media (prefers-reduced-motion: reduce) {
.page-enter {
animation-name: page-enter-still;
}
}부딪힌 것 2 — 뒤로가기에서는 전환이 안 걸린다
브라우저 뒤로가기를 누르면 startViewTransition이 아예 호출되지 않았습니다. 방향 문제가 아니라 popstate 자체가 전환을 안 탑니다.
Next 소스에 이유가 주석으로 적혀 있습니다.
// node_modules/next/dist/client/components/app-router-instance.js
// most of the action types are async with the exception of restore
// it's important that restore is handled quickly since it's fired on the popstate event
// and we don't want to add any delay on a back/forward nav
if (payload.type !== ACTION_RESTORE) {
startTransition(() => { setState(deferredPromise); });
}popstate가 만드는 ACTION_RESTORE만 startTransition 밖에서 동기로 처리합니다. React의 <ViewTransition>은 Transition·Suspense·useDeferredValue에서만 활성화되므로, 동기 setState는 전환을 트리거하지 않습니다.
router.back()을 쓰면 다를까 싶었지만 아니었습니다.
back: () => window.history.back(),
forward: () => window.history.forward(),같은 경로를 탑니다. 정리하면 이렇습니다.
| 이동 방식 | 전환 |
|---|---|
<Link> 클릭 | O |
router.push() / replace() | O |
| 브라우저 뒤로/앞으로 버튼 | X |
router.back() / history.back() | X |
의도된 설계입니다. 뒤로가기는 이미 본 화면으로 돌아가는 동작이라 지연을 넣지 않겠다는 것.
부딪힌 것 3 — 스냅샷이 뷰포트 기준이 아니다
목록을 1400px 내린 상태에서 글을 클릭했더니, 전환 중간 프레임에 목록 맨 위가 나타났습니다. 화면이 위로 튀었다가 사라집니다.
전환 중 실제로 도는 애니메이션을 찍어 보니 제가 지정하지 않은 게 하나 더 있었습니다.
-ua-view-transition-group-anim-_t_0_ 250ms
page-enter 200ms
page-exit 140ms첫 줄이 브라우저 기본 group 애니메이션입니다. ::view-transition-group은 요소의 크기와 위치를 옛 값에서 새 값으로 보간합니다. 목록과 글은 main 높이가 크게 다르고 이동 시 스크롤이 0으로 리셋되니, 250ms 동안 스냅샷이 늘어나면서 위로 밀립니다.
더 근본적인 문제는 스냅샷이 잡히는 범위입니다. 뷰포트가 아니라 요소 전체 높이로 잡힙니다. 그래서 스크롤 위치와 무관하게 요소 top이 기준이 됩니다. 긴 콘텐츠를 담은 요소에 이름을 주면 이 문제가 따라옵니다.
group 보간을 끄고 옛 스냅샷을 아예 그리지 않는 것으로 해결했습니다.
::view-transition-group(.page) { animation: none; }
::view-transition-old(.page) { display: none; }이러면 배경만 남은 상태에서 새 콘텐츠가 올라옵니다. Claude 블로그가 오버레이로 만드는 것과 같은 결과를, 오버레이 DOM 없이 얻습니다.
부딪힌 것 4 — 길게 늘릴 수 없다
여기까지 고치고도 원하던 느낌이 안 났습니다. 그래서 기준 사이트의 등장 애니메이션을 다시 측정했습니다.
Webflow의 IX2는 rAF로 인라인 style을 직접 씁니다. Web Animations API에 안 잡히므로 getComputedStyle을 rAF마다 샘플링했습니다.
1049ms:0.11 1211ms:0.486 1411ms:0.784 1611ms:0.936 1928ms:0.999900ms가 넘습니다. 제가 쓰던 280ms의 3배 이상입니다. 곡선을 이징에 대보면 easeOutCubic에 거의 일치합니다.
| 진행률 | 측정값 | easeOutCubic |
|---|---|---|
| 25% | 0.52 | 0.578 |
| 50% | 0.84 | 0.875 |
| 75% | 0.97 | 0.984 |
75% 시점에 이미 97%가 진행되고, 남은 3%를 마지막 225ms에 걸쳐 소화합니다. 부드럽게 읽히는 건 이 꼬리 구간입니다.
문제는 이 900ms를 View Transition 안에 넣을 수 없다는 점입니다. 전환 중에는 화면이 스냅샷으로 고정돼 스크롤해도 화면이 안 움직입니다. 1초 가까이 스크롤이 막힙니다.
TIP
전환 중 클릭은 막히지 않습니다. ::view-transition 레이어는 pointer-events: none이라 아래 실제 DOM으로 통과합니다. 갇히는 건 스크롤입니다.
그리고 이 지점에서 처음 측정이 다시 떠올랐습니다. Claude 블로그는 이 애니메이션을 페이지 전환에 넣지 않습니다. 새 문서가 로드된 뒤 일반 애니메이션으로 콘텐츠를 등장시킵니다. 그래서 900ms를 써도 그동안 스크롤이 정상입니다.
4단계 — 방법 C로 갈아엎는다
View Transition을 전부 걷어냈습니다. next.config.ts의 플래그도 원복했습니다.
컴포넌트
"use client";
import { usePathname } from "next/navigation";
interface PageTransitionProps {
children: React.ReactNode;
}
export function PageTransition({ children }: PageTransitionProps) {
const pathname = usePathname();
return (
<div key={pathname} className="page-enter">
{children}
</div>
);
}동작하는 이유는 key 하나입니다.
CSS 애니메이션은 요소가 DOM에 새로 삽입될 때만 재생됩니다. className만 붙여두면 첫 마운트에서 한 번 돌고 끝입니다. React는 key가 바뀌면 같은 위치의 같은 타입 요소라도 재사용하지 않고 파기 후 재생성하므로, 경로가 바뀔 때마다 브라우저 입장에서는 새 요소가 들어온 것이 됩니다.
"use client"가 필요한 건 usePathname이 클라이언트 훅이기 때문입니다. 다만 children은 클라이언트 번들로 끌려가지 않습니다. 서버 컴포넌트를 props로 받기만 하면 서버에서 렌더된 결과가 그대로 전달됩니다. 안에서 직접 import 했다면 얘기가 달랐을 겁니다.
애니메이션
.page-enter {
animation: page-enter 900ms cubic-bezier(0.33, 1, 0.68, 1) backwards;
}
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.page-enter {
animation-name: page-enter-still;
}
}
@keyframes page-enter-still {
from { opacity: 0; }
to { opacity: 1; }
}opacity와 transform만 쓴 이유는 브라우저 렌더링 파이프라인에 있습니다.
Style → Layout → Paint → Layerize / CompositePaint는 이름과 달리 픽셀을 칠하는 단계가 아닙니다. "이 위치에 이 색 사각형", "이 위치에 이 폰트로 글자" 같은 그리기 명령 목록만 만듭니다.
Layerize / Composite — 그 명령 목록을 실행해 실제 픽셀 색값을 계산하고(래스터화), 결과를 여러 장의 비트맵으로 나눠 GPU에 올린 뒤, 겹쳐서 최종 화면을 만듭니다. transform, opacity, will-change가 걸린 요소는 별도 레이어로 승격되어, 메인 스레드 없이 컴포지터 스레드에서만 움직일 수 있습니다. 그래서 left를 애니메이션하면 Layout부터 다시 돌지만, transform을 애니메이션하면 Composite만 돌아서 60fps가 나옵니다.
용어를 풀면 이렇습니다. 비트맵은 픽셀 하나하나의 색값을 나열한 데이터, 곧 이미지입니다. 래스터화는 그리기 명령을 그 색값으로 바꾸는 계산입니다. 폰트에서 글자 외곽선을 꺼내 어느 픽셀이 글자에 속하는지 정하는 작업이 여기 들어갑니다.
승격은 한 요소를 자기 전용 비트맵으로 떼어내는 것입니다. 기본적으로는 여러 요소가 한 장에 함께 그려집니다. 떼어내기 전에는 요소를 움직일 때마다 그 비트맵을 통째로 다시 계산해야 하고, 이 작업은 메인 스레드가 합니다. 떼어낸 뒤에는 비트맵을 그대로 두고 "이 이미지를 12px 위에 알파 0.4로 겹쳐라"만 GPU에 넘기면 됩니다.
애니메이션 대상이 되는 순간 브라우저가 자동으로 승격시키고, 끝나면 해제합니다. 정적으로 값만 지정된 요소가 항상 승격되는 것은 아닙니다.
말로만 믿을 필요는 없습니다. Chrome DevTools 프로토콜의 LayerTree 도메인이 레이어 목록과 승격 사유를 문장으로 돌려줍니다. 이 블로그에서 페이지를 이동하는 동안 잡은 결과입니다.
div.page-enter 2560x4953
→ Has an active accelerated transform animation or transition.
→ Has an active accelerated opacity animation or transition.
header.border-border/60.bg-background/80 2560x114
→ Is sticky position., Has a backdrop filter.div.page-enter가 transform·opacity 애니메이션 때문에 올라간 게 그대로 보입니다. 900ms가 지난 뒤 다시 조회하면 이 사유로 승격된 레이어는 없습니다. DevTools에서 ⌘⇧P → "Show Layers"로도 같은 것을 볼 수 있습니다.
속성별로 다시 도는 단계입니다.
| 애니메이션할 속성 | 다시 도는 단계 |
|---|---|
transform, opacity | Composite |
filter: blur() | Paint → Composite |
height, top, margin | Layout → Paint → Composite |
900ms 동안 매 프레임 도는 애니메이션이라, 여기서 filter: blur()를 끼우면 긴 글에서 프레임이 떨어집니다. 메인 스레드가 다른 작업으로 막혀도 애니메이션이 끊기지 않는 것 역시 컴포지터 스레드에서 돌기 때문입니다.
fill-mode를 backwards로 둔 이유는 종료 후 transform을 남기지 않기 위해서입니다.
| 값 | 시작 전 | 종료 후 |
|---|---|---|
| 없음 | 원래 스타일 → 첫 프레임 깜빡임 | 원래 스타일 |
both | from 상태 | to 상태 유지 |
backwards | from 상태 | 원래 스타일로 복귀 |
both로 두면 끝난 뒤에도 transform: none이 적용된 상태로 남습니다. 값이 none이라 보기엔 무해하지만, 글 페이지 목차가 sticky top-24라 조상에 transform이 걸려 있으면 영향을 받을 수 있습니다. backwards는 애니메이션이 끝나면 선언을 놓습니다.
붙인 위치
<Header />
<main className="flex-1 py-10">
<PageTransition>{children}</PageTransition>
</main>
<Footer />main 안쪽입니다. 헤더와 푸터는 바깥이라 애니메이션 대상이 아닙니다. 매 이동마다 헤더까지 흐려지면 화면의 고정점이 사라집니다.
결과
Playwright로 다시 측정했습니다.
| 항목 | 결과 |
|---|---|
page-enter 재생 | 900ms, 이동할 때마다 |
startViewTransition 호출 | 0 |
| 재생 중 스크롤 | scrollTo(600) → scrollY = 600 |
카테고리 필터(/?category=typescript) | 재생 안 함 |
세 번째 줄이 갈아엎은 이유 전부입니다. View Transition에서는 스냅샷에 갇혀 900ms를 쓸 수 없었습니다.
네 번째 줄은 의도한 동작입니다. usePathname은 쿼리스트링을 보지 않으므로 /?category=react로 필터만 바꾸는 이동에서는 재생되지 않습니다. 목록 안에서 항목만 갈리는 것이라 전체 등장 연출은 과합니다.
부작용
key 재마운트는 이전 DOM을 통째로 파기합니다. 페이지가 바뀌는 상황이라 어차피 내용이 전부 갈리므로 낭비는 아닙니다. 다만 main 안쪽 컴포넌트의 로컬 상태는 매 이동마다 초기화됩니다. 지금 구조에서는 목차·정렬 메뉴가 모두 페이지에 종속돼 문제가 없지만, 페이지를 옮겨도 유지돼야 하는 상태가 생기면 URL이나 상위로 올려야 합니다.
언제 무엇을 쓸까
View Transitions API가 나쁜 API라는 얘기가 아닙니다. 잘 맞는 자리가 따로 있습니다.
- 공유 요소 morph — 목록 썸네일이 상세 히어로 위치로 확대 이동하는 것. 두 페이지의 요소를 이름으로 묶어 브라우저가 위치를 보간해 줍니다. 이건 CSS 애니메이션으로 직접 만들기 어렵습니다.
- 짧은 크로스페이드 — 200~300ms처럼 스크롤이 잠깐 멈춰도 문제없는 길이.
반대로 긴 등장 연출은 View Transition에 넣지 않는 편이 낫습니다. 스냅샷이 화면을 덮고 있는 시간이 그대로 스크롤이 막히는 시간입니다. 실제 DOM 위에서 도는 일반 애니메이션은 그 제약이 없습니다.
처음에 이름만 보고 "페이지 전환이니까 View Transition"이라고 골랐던 게 두 번 헛돈 이유였습니다. 따라 하려던 사이트를 먼저 측정했으면 첫 단계에서 알았을 내용입니다. 실제로 측정은 했는데, 거기서 얻은 결론을 구현에 반영하지 않았습니다.
Comments