
회사에서 Vue 프로젝트를 React로 변경하는 내부 개선 과제를 진행하고 있습니다.
관련해서 올라온 PR 중 한 팀원 분이 ViewTransition API를 사용해서 내부 라우팅 변경(페이지 전환) 시에 부드럽게 페이지 전환이 되는 css를 넣어주셨습니다.
(fade-in 시 오른쪽에서 왼쪽으로, fade-out 시 다시 오른쪽으로 나가는 그런 효과..👍)
일단 ViewTransition API를 몰랐습니다.(머쓱 😉) 그래서 이게 뭘까 하고 조금 찾아보았습니다. 그리고 적용된 코드를 개발 환경에서 이리 저리 돌려보다가 버튼 클릭으로 라우팅이 변경되는 경우에는 적용한 css 효과가 화면에 잘 보이는데 브라우저 기본 동작인 앞 뒤로 이동하는 경우에는 라우터가 변경됐음에도 불구하고 애니메이션 효과가 전혀 보이지 않았습니다.
그래서 왜 이러지? 왜 이럴까? 🤔 브라우저 이동 시에도 적용 됐으면 좋겠는데? (❌ 안 되는 건 없어!)
해결을 위해 chat-gap에게 물어보고 검색도 하면서 결국 원인을 파악하고 해결까지 할 수 있었습니다.✌️ 그래서 오늘은 그 부분을 한 번 정리해보려고 합니다.
View Transition API란?
SPA 또는 MPA에서 부드라운 뷰 전환(화면 전환)을 구현하기 위한 웹 표준 API
🎯 포인트
⚙️ 어떻게 동작하나?
브라우저는 두 뷰 간의 전환 과정에서:
1. 현재 뷰를 캡처하고,
2. 다음 뷰가 렌더링될 때까지 대기한 뒤,
3. 캡처한 현재 뷰와 새로운 뷰 사이에 전환 애니메이션을 적용함.
그리고 이 모든 걸 document.startViewTransition API 하나로 제어 ✨
document.startViewTransition(() => {
// 이 안에서 상태 변경 or DOM 업데이트 발생
// 예: 라우터 이동, DOM 교체 등
navigateTo('/about');
});
// css 파일에서 이렇게 커스텀 애니메이션을 줄 수 있습니다. view-transition-${x} 가상 선택자 활용
::view-transition-old(root),
::view-transition-new(root) {
animation: fade 0.5s ease;
}
Chrome for developers about view transition api
구글 문서에 꽤나 정리가 잘 되어 있고 데모 사이트도 있습니다. 데모 사이트에서는 브라우저 뒤로 가기를 해도 너무나 자연스럽게 애니메이션 효과가 적용되었기 때문에 역시나 안 되는 건 없고! 다 할 수 있다는 생각으로 지피티를 괴롭히기 시작했습니다.
(데모 사이트의 깃헙으로 들어가서 코드를 봤는데 해당 사이트는, 일단 spa가 아닌 여러 개의 html을 가진 multi page 형태인 것 같아서 조금 보다가 지피티를 괴롭히는 것으로 결정!)
지피티한테 왜 뒤로가기, 앞으로 가기를 했을 때는 css 효과가 나타나지 않는 거냐 물었더니, 브라우저 히스토리 POP Action 발생 시에는 직접적인 DOM 변경이 아니라서 document.startViewTransition 이 트리거 되지 않는다고 했습니다.
이게 무슨 말일까요? 직접적인 DOM 변경이 아니라뇨?
path가 바뀌면 라우터에서 매치되는 컴포넌트를 찾고 다시 리렌더를 하기 때문에 우리가 라우팅 라이브러리를 쓰는 게 아니겠어요? 😔
리렌더가 일어난다는 건 이전 DOM과 업데이트 된 DOM의 상태를 비교해서 "다시" 그리는 것이니 당연히 직접적인 DOM 변경이 일어나는 게 아니겠어요? 😔
직접적인 DOM 변경이라는 말이 이해 되지 않아서 다시 물어봤습니다.
document.startViewTransition()이 “DOM 변화 직전과 직후”를 비교해서 애니메이션을 만드는 타이밍에 있음!
즉, DOM이 실제로 바뀌냐 마냐의 문제가 아닌, "DOM이 언제 바뀌고 View Transition이 언제 트리거 되는가" 를 고려해야 한다는 점입니다.
🎯 View Transition API의 동작 방식 요약
document.startViewTransition(() => {
// 이 안에서 DOM이 바뀌어야 함 (리렌더든 innerHTML 변경이든 등)
});
이러면 브라우저는 ...
1. 실행 전 DOM 스냅샷을 찍고(old),
2. 콜백 함수 내부에서 DOM이 바뀌고
3. 그걸 new로 간주해서 old <-> new 사이의 애니메이션을 만듦.
React에서 라우터 상태 변경 시에 우리는 navigate 함수를 호출하는데 React의 DOM 업데이트는 비동기적입니다.
naviage(path); // 상태 변경
이렇게 함수를 호출하면 React는 1. 상태를 변경하고 2. 그 다음 렌더 사이클에 DOM을 업데이트합니다.
그래서 만약 아래와 같이 코드를 짠다면 old === new 가 되기 때문에(startViewTransition 트리거 되는 시점에는 이미 new 상태로 변경되어 있지만 DOM은 안 바뀌었기 때문에 old의 dom이 new의 dom이랑 같다고 착각) 애니메이션 효과를 제대로 노출할 수가 없는 것입니다.
navigate(path);
document.startViewTransition(() => {
// 여긴 이미 바뀐 상태, DOM은 아직 업데이트 전,(DOM은 다음 tick에 바뀜.)
// 즉 👉 이때 startViewTransition()은 DOM 변화가
// 아직 일어나지 않은 시점에 실행되기 때문에 브라우저는 old === new 라고 인식.
});
즉, ❌ 'View Transition API가 요구하는 “DOM 변화의 정확한 타이밍 안”에서 안 일어나면 쓸모가 없다'는 게 이유입니다.

import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
export function useViewTransitionOnPop() {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
if (!document.startViewTransition) return;
const url = new URL(window.location.href);
const newPath = url.pathname;
// DOM 변화는 navigate가 담당
document.startViewTransition(() => {
navigate(newPath, { replace: true });
});
// 기본 popstate 동작 막기
event.preventDefault();
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [navigate]);
}
처음에 지피티가 저한테 던져준 코드는 이랬습니다. 이렇게 코드를 작성하면 POP 액션 시에 다시 history stack에 새로운 위치를 쌓게 되기 때문에 애니메이션이 잘 적용된 라우팅이 가능하고 뒤로 가기 시에도 애니메이션 효과는 잘 적용이 되지만 실제로는 히스토리 스택의 index 변경이 아니라 (브라우저의 동작처럼 앞으로 뒤로 가기가 아니라) 계속해서 스택에 쌓이는 구조라 앞으로는 절대 갈 수가 없고 계속 빠르게 뒤로 가기를 하면 스택이 꼬여버리는 사이드 이펙트가 발생할 수 있습니다.
그래서 아니 진짜 이 방법 밖에 없는 걸까? 하고 계속 지피티를 괴롭혔...아니 질문을 했습니다. :)
계속 얘기를 나누다보니 useBlocker 를 활용한 코드를 던져줬습니다.
import { useEffect } from 'react';
import {
unstable_useBlocker as useBlocker,
useLocation,
useNavigate,
} from 'react-router-dom';
export function useViewTransitionRouter() {
const blocker = useBlocker(true);
const navigate = useNavigate();
useEffect(() => {
if (blocker.state === 'blocked') {
const nextLocation = blocker.location;
if (document.startViewTransition) {
document.startViewTransition(() => {
blocker.proceed(); // 실제 라우팅 진행
});
} else {
blocker.proceed(); // fallback
}
}
}, [blocker]);
}
useBlocker라는 훅은 처음 봤기 때문에 바로 공식문서에서 찾아보니 훅 호출 시 boolean 값이나 콜백을 넘기면(return boolean) 결과 값에 따라 라우팅 이동을 진행할지 막을지를 결정하는 훅이었습니다.
이걸 활용하면 pop historyaction 발생 시에만 잠시 blocking을 했다가 startViewTransition 콜백 함수 안에서 proceed 함수를 호출해 깔끔하게 애니메이션이 적용된 라우터 이동을 할 수 있을 것 같다는 생각이 들었습니다.
import { useEffect } from 'react';
import { useBlocker } from 'react-router-dom';
export function useSmoothPageTransition() {
const blocker = useBlocker(({ historyAction }) => {
return historyAction === 'POP';
});
useEffect(() => {
if (blocker.state === 'blocked') {
document.startViewTransition(() => {
blocker.proceed();
});
}
}, [blocker]);
}
그래서 다음과 같이 코드를 작성해서 테스트 해봤고 원하는 대로 동작하는 걸 확인할 수 있었습니다. 👍
이걸 해당 API를 활용해 작업해주셨던 팀원분께 공유했고 그분이 다른 수정사항과 같이 해당 부분도 조금 더 디벨롭해서 작업해주셨습니다. (개큰감사)
(⛔️ 브라우저 서포팅 부분을 챙겨합니다! 크롬 111 버전 이상에서만 동작하기 때문에 document.startViewTransition 함수가 있는지 확인해 하고, 사파리나 파이어폭스에서는 지원 ❌)
(참고: can i use에는 사파리 18버전 이상에서 된다고 되어 있는데 18.3.1 버전에서 테스트 했을 때는 안 되긴 했습니다..)
🚨 브라우저 지원
Chrome 111+ 에서 지원 (기준 2025년 현재, Edge는 지원, Firefox/Safari는 아직 미지원 Safari는 18버전 이상부터 사용 가능)
Can I use? 참고