이 글은 누구를 위한 것인가
- SPA에서 네이티브 앱 수준의 페이지 전환 효과를 구현하려는 팀
- CSS만으로 공유 요소 전환 애니메이션을 만들고 싶은 개발자
- React Router/Next.js에 View Transitions를 통합하려는 팀
들어가며
View Transitions API 이전에는 페이지 전환 애니메이션을 위해 복잡한 라이브러리나 직접 DOM 조작이 필요했다. document.startViewTransition()은 상태 변경 전후의 스냅샷을 자동으로 캡처하고 CSS로 전환 효과를 정의한다.
이 글은 bluefoxdev.kr의 View Transitions API 가이드 를 참고하여 작성했습니다.
1. View Transitions API 개요
[View Transitions 동작 원리]
1. document.startViewTransition(callback) 호출
2. 현재 상태 스냅샷 캡처 (::view-transition-old)
3. callback 실행 (DOM 업데이트)
4. 새 상태 스냅샷 캡처 (::view-transition-new)
5. 두 스냅샷을 겹쳐서 CSS 애니메이션 실행
6. 전환 완료 후 스냅샷 제거
[슈도 엘리먼트 트리]
::view-transition (루트 오버레이)
└─ ::view-transition-group(name) (각 named element)
└─ ::view-transition-image-pair(name)
├─ ::view-transition-old(name) (이전 스냅샷)
└─ ::view-transition-new(name) (새 스냅샷)
[기본 전환]
::view-transition-old(root) → opacity: 1→0
::view-transition-new(root) → opacity: 0→1
지속시간: 250ms
[view-transition-name]
CSS 속성으로 요소에 전환 이름 부여
같은 이름의 old/new 요소가 연결되어 이동 애니메이션 자동 생성
각 이름은 페이지에서 유일해야 함
[브라우저 지원]
Chrome 111+, Edge 111+
Firefox: 플래그 필요
Safari: 개발 중
2. View Transitions 구현
// 기본 페이지 전환
async function navigateTo(url) {
if (!document.startViewTransition) {
// 폴백: 전환 없이 이동
window.location.href = url;
return;
}
const transition = document.startViewTransition(async () => {
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
// DOM 업데이트
document.getElementById('main').innerHTML =
newDoc.getElementById('main').innerHTML;
document.title = newDoc.title;
history.pushState({}, '', url);
});
// 전환 완료 대기
await transition.ready;
console.log('전환 시작');
await transition.finished;
console.log('전환 완료');
}
/* 기본 페이드 전환 커스터마이징 */
::view-transition-old(root) {
animation: 300ms ease-in both fade-out;
}
::view-transition-new(root) {
animation: 300ms ease-out both fade-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* 슬라이드 전환 */
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slide-out-left {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
.slide-transition::view-transition-old(root) {
animation: 350ms ease both slide-out-left;
}
.slide-transition::view-transition-new(root) {
animation: 350ms ease both slide-in-right;
}
/* 공유 요소 전환 (Shared Element Transition) */
/* 목록 페이지의 카드 */
.product-card[data-id="42"] {
view-transition-name: product-42; /* 동적으로 설정 */
}
/* 상세 페이지의 주인공 이미지 */
.product-hero {
view-transition-name: product-42; /* 같은 이름 → 연결됨 */
}
/* 전환 중 공유 요소 스타일 */
::view-transition-group(product-42) {
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 공유 요소를 제외한 나머지 페이드 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 200ms;
}
// React Router v6.12+ View Transitions 통합
import { Link, useNavigate } from 'react-router-dom';
// Link 컴포넌트에 unstable_viewTransition 추가
function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
<Link
to={`/products/${product.id}`}
unstable_viewTransition
>
<img
src={product.image}
style={{ viewTransitionName: `product-image-${product.id}` }}
/>
<span>{product.name}</span>
</Link>
</li>
))}
</ul>
);
}
// 프로그래매틱 탐색
function ProductCard({ product }) {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/products/${product.id}`, {
unstable_viewTransition: true,
});
};
return (
<div
style={{ viewTransitionName: `product-card-${product.id}` }}
onClick={handleClick}
>
{product.name}
</div>
);
}
// Next.js App Router (실험적)
// next.config.js: experimental.viewTransition = true
'use client';
import { useRouter } from 'next/navigation';
function NavLink({ href, children }) {
const router = useRouter();
const handleClick = (e) => {
e.preventDefault();
if (!document.startViewTransition) {
router.push(href);
return;
}
document.startViewTransition(() => {
router.push(href);
});
};
return <a href={href} onClick={handleClick}>{children}</a>;
}
// 전환 방향에 따른 애니메이션 분기
let isBack = false;
window.addEventListener('popstate', () => {
isBack = true;
});
async function navigate(url) {
const transition = document.startViewTransition(async () => {
await updateDOM(url);
});
await transition.ready;
// 방향에 따라 다른 애니메이션
const direction = isBack ? -1 : 1;
document.documentElement.animate(
[
{ transform: `translateX(${direction * 100}%)` },
{ transform: 'translateX(0)' },
],
{
duration: 300,
easing: 'ease',
pseudoElement: '::view-transition-new(root)',
}
);
document.documentElement.animate(
[
{ transform: 'translateX(0)' },
{ transform: `translateX(${-direction * 100}%)` },
],
{
duration: 300,
easing: 'ease',
pseudoElement: '::view-transition-old(root)',
}
);
isBack = false;
}
// 접근성: 모션 감소 선호 대응
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
마무리
View Transitions API의 핵심은 view-transition-name으로 old/new DOM 요소를 연결하면 브라우저가 자동으로 이동 애니메이션을 생성한다는 것이다. CSS 슈도 엘리먼트(::view-transition-old, ::view-transition-new)로 전환 효과를 완전히 커스터마이징할 수 있다. prefers-reduced-motion으로 접근성을 보장하고, 미지원 브라우저는 document.startViewTransition 존재 확인으로 폴백 처리한다.