
View Transitions API: 페이지 전환 애니메이션의 미래
CSS 애니메이션만으로 페이지 전환을 자연스럽게 만들 수 있다고? document.startViewTransition() 한 줄이 바꾸는 UX를 직접 확인해봐.

CSS 애니메이션만으로 페이지 전환을 자연스럽게 만들 수 있다고? document.startViewTransition() 한 줄이 바꾸는 UX를 직접 확인해봐.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

iOS 앱에서 카드를 탭하면 부드럽게 펼쳐지고, Android에서 화면이 슬라이드인되는 그 느낌. 웹에서 이걸 구현하려면 예전엔 JavaScript 애니메이션 라이브러리를 깔고, DOM 조작을 직접 하고, 성능 이슈랑 싸워야 했다.
Framer Motion이나 GSAP을 쓰면 어느 정도 되긴 했는데, 페이지 간 전환은 여전히 골치였다. SPA에서 라우트가 바뀔 때 이전 페이지가 사라지고 새 페이지가 나타나는 순간을 제어하려면, 상태 관리랑 애니메이션 타이밍을 맞추는 게 진짜 복잡해졌다.
그런데 브라우저 자체가 이 문제를 해결하겠다고 나섰다. View Transitions API다. 브라우저가 스크린샷을 찍고, DOM을 업데이트하고, 두 상태 사이를 CSS로 애니메이션하는 방식. 라이브러리 없이, 복잡한 상태 없이.
페이지 A에서 페이지 B로 이동할 때 어떤 일이 일어나는지 생각해봐.
전통적인 MPA (Multi-Page Application):1. 링크 클릭
2. 브라우저가 새 URL로 요청
3. 서버가 HTML 응답
4. 기존 페이지 완전 제거
5. 새 페이지 렌더링
→ 흰 화면 깜빡임, 점프
기존 SPA 전환:
// React Router + Framer Motion 조합
// 이것만 해도 설정이 꽤 복잡하다
const variants = {
initial: { opacity: 0, x: -200 },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: 200 }
};
function PageWrapper({ children }) {
return (
<motion.div
initial="initial"
animate="in"
exit="out"
variants={variants}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}
// 그리고 AnimatePresence도 설정해야 하고...
문제는 이 방식이 CSS 레이어에서만 동작한다는 거야. 요소 간 공유 전환(shared element transition)—카드가 상세 페이지로 펼쳐지는 효과—을 구현하려면 완전히 다른 접근이 필요했다.
브라우저가 직접 개입한다. 동작 원리를 비유로 설명하면:
마치 영화 촬영에서 카메라가 한 장면을 찍고, 세트를 바꾼 다음, 다시 찍어서 모핑하는 것처럼.
old 상태)new 상태)이게 전부다. 나머지는 CSS ::view-transition 슈도 엘리먼트로 제어.
// DOM을 바꾸기 전에 이걸 감싸기만 하면 된다
document.startViewTransition(() => {
document.querySelector('#content').innerHTML = newContent;
});
기존 코드가 이랬다면:
document.querySelector('#content').innerHTML = newContent;
그냥 startViewTransition으로 감싸는 것만으로 자동으로 크로스페이드 애니메이션이 생긴다. 추가 CSS 없이.
async function navigateToPage(url) {
// startViewTransition에 비동기 함수도 넣을 수 있다
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');
document.querySelector('main').replaceWith(
newDoc.querySelector('main')
);
// 타이틀도 업데이트
document.title = newDoc.title;
});
// transition.ready: 애니메이션 시작 시점
// transition.finished: 애니메이션 완료 시점
await transition.finished;
console.log('페이지 전환 완료');
}
const transition = document.startViewTransition(updateFn);
// transition.ready: 애니메이션 준비 완료 (슈도 엘리먼트 생성됨)
transition.ready.then(() => {
// 여기서 Web Animations API로 커스터마이징 가능
});
// transition.finished: 모든 애니메이션 완료
transition.finished.then(() => {
console.log('done');
});
// transition.updateCallbackDone: updateFn 실행 완료
transition.updateCallbackDone.then(() => {
// DOM 업데이트는 끝났지만 애니메이션은 진행 중
});
// 애니메이션 건너뛰기 (접근성, 테스트용)
transition.skipTransition();
View Transition이 시작되면 브라우저가 이런 트리를 만들어:
::view-transition ← 루트 오버레이
└── ::view-transition-group(root) ← 전체 페이지 그룹
└── ::view-transition-image-pair(root)
├── ::view-transition-old(root) ← 이전 화면
└── ::view-transition-new(root) ← 새 화면
/* 기본 크로스페이드 속도 조절 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
/* 이전 페이지는 왼쪽으로 슬라이드 아웃 */
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in forwards;
}
/* 새 페이지는 오른쪽에서 슬라이드 인 */
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out forwards;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
}
/* 사용자가 모션 감소를 선호하면 즉시 전환 */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
이게 진짜 킬러 피처다. 카드에서 상세 페이지로 이동할 때 카드 이미지가 부드럽게 커지는 효과.
/* 카드의 이미지에 고유 이름 부여 */
.card-image {
view-transition-name: hero-image;
}
/* 상세 페이지의 히어로 이미지도 같은 이름 */
.detail-hero {
view-transition-name: hero-image;
}
같은 view-transition-name을 가진 요소들은 브라우저가 자동으로 위치와 크기를 모핑해줘.
// 클릭한 카드에만 transition name을 동적으로 부여
function handleCardClick(cardId) {
const card = document.querySelector(`[data-id="${cardId}"] img`);
document.startViewTransition(() => {
// 전환 전: 카드에 이름 부여
card.style.viewTransitionName = 'selected-card';
// DOM 업데이트
navigateToDetail(cardId);
});
}
// 상세 페이지에서도 같은 이름
// detail-page img { view-transition-name: selected-card; }
import { useNavigate } from 'react-router-dom';
function ProductCard({ product }) {
const navigate = useNavigate();
const handleClick = () => {
// view-transition-name을 인라인 스타일로 동적 설정
const imgEl = document.querySelector(`#product-img-${product.id}`);
if (imgEl) {
imgEl.style.viewTransitionName = 'product-hero';
}
if ('startViewTransition' in document) {
document.startViewTransition(() => {
navigate(`/products/${product.id}`);
});
} else {
navigate(`/products/${product.id}`);
}
};
return (
<div className="card" onClick={handleClick}>
<img
id={`product-img-${product.id}`}
src={product.image}
alt={product.name}
/>
<h3>{product.name}</h3>
</div>
);
}
// 상세 페이지
function ProductDetail({ product }) {
return (
<div>
<img
style={{ viewTransitionName: 'product-hero' }}
src={product.image}
alt={product.name}
/>
</div>
);
}
| 구분 | MPA (Multi-Page App) | SPA (Single-Page App) |
|---|---|---|
| 지원 여부 | Chrome 126+에서 네이티브 지원 | 이미 지원 (JavaScript) |
| 설정 방법 | HTML meta 태그 또는 HTTP 헤더 | document.startViewTransition() |
| 복잡도 | 매우 쉬움 | 중간 |
| 공유 요소 전환 | CSS만으로 가능 | JS + CSS |
Chrome 126부터 MPA에서도 View Transitions를 지원한다. HTML에 한 줄만 추가하면 돼:
<!-- HTML head에 추가 -->
<meta name="view-transition" content="same-origin" />
또는 HTTP 응답 헤더로:
View-Transition: same-origin
이것만으로 같은 origin 내의 페이지 이동에 자동 크로스페이드가 적용돼. PHP, Django, Rails 같은 서버사이드 렌더링 앱에서도 JavaScript 없이 페이지 전환 애니메이션이 생긴다.
<!-- 목록 페이지 -->
<img
src="/products/1.jpg"
style="view-transition-name: product-1"
/>
<!-- 상세 페이지 -->
<img
src="/products/1.jpg"
style="view-transition-name: product-1"
/>
CSS만으로 두 페이지 사이 이미지가 모핑된다. 정말 마법 같다.
Next.js App Router는 아직 공식적으로 View Transitions를 지원하지 않지만, 직접 구현할 수 있어.
// src/components/ViewTransitionLink.tsx
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { MouseEvent } from 'react';
interface Props {
href: string;
children: React.ReactNode;
className?: string;
}
export function ViewTransitionLink({ href, children, className }: Props) {
const router = useRouter();
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (!('startViewTransition' in document)) {
router.push(href);
return;
}
document.startViewTransition(() => {
router.push(href);
});
};
return (
<a href={href} onClick={handleClick} className={className}>
{children}
</a>
);
}
// 앞으로 이동인지 뒤로 이동인지에 따라 다른 애니메이션
'use client';
import { useRouter } from 'next/navigation';
type Direction = 'forward' | 'backward';
function navigate(url: string, direction: Direction) {
if (!('startViewTransition' in document)) {
window.location.href = url;
return;
}
// data-attribute로 방향 정보 전달
document.documentElement.dataset.direction = direction;
document.startViewTransition(() => {
window.location.href = url;
});
}
/* 방향에 따른 CSS 애니메이션 */
[data-direction="forward"]::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in forwards;
}
[data-direction="forward"]::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out forwards;
}
[data-direction="backward"]::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in forwards;
}
[data-direction="backward"]::view-transition-new(root) {
animation: slide-in-left 0.3s ease-out forwards;
}
@keyframes slide-out-left {
to { transform: translateX(-30px); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(30px); opacity: 0; }
}
@keyframes slide-out-right {
to { transform: translateX(30px); opacity: 0; }
}
@keyframes slide-in-left {
from { transform: translateX(-30px); opacity: 0; }
}
Next.js 팀이 View Transitions를 실험적으로 도입하는 방향으로 논의 중이다. unstable_viewTransition 같은 API가 등장할 가능성이 있어. Remix는 이미 unstable_viewTransition prop을 <Link>에 추가했다:
// Remix에서의 사용법 (참고용)
import { Link } from '@remix-run/react';
function App() {
return (
<Link to="/about" unstable_viewTransition>
About
</Link>
);
}
/* 이렇게 하면 안 됨 - 같은 이름이 두 개면 전환이 안 됨 */
.card-1 { view-transition-name: card; }
.card-2 { view-transition-name: card; } /* 충돌! */
/* 이렇게 해야 함 */
.card-1 { view-transition-name: card-1; }
.card-2 { view-transition-name: card-2; }
동적으로 할당할 때:
/* CSS custom properties + :nth-child 조합 */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
/* ... */
또는 JavaScript로:
document.querySelectorAll('.card').forEach((card, i) => {
card.style.viewTransitionName = `card-${i}`;
});
view-transition-name을 가진 요소의 부모에 overflow: hidden이 있으면 전환이 클리핑될 수 있어. 필요하면 부모 요소에 contain: layout을 추가하거나 DOM 구조를 조정해.
// updateFn 내에서 레이아웃을 크게 변경하면 전환이 어색해질 수 있다
document.startViewTransition(() => {
// 좋지 않음: 갑작스런 레이아웃 변경
document.body.style.overflow = 'hidden';
updateContent();
// 더 좋음: 전환 후에 overflow 변경
updateContent();
});
transition.finished.then(() => {
document.body.style.overflow = '';
});
View Transitions는 점진적 향상(progressive enhancement)의 교과서적 예시야. 지원 안 하는 브라우저에서도 정상 동작하게 해야 해.
function transitionNavigate(updateFn) {
// 지원 여부 체크
if (!('startViewTransition' in document)) {
// 폴백: 그냥 업데이트
updateFn();
return Promise.resolve();
}
// 지원하면 전환 적용
return document.startViewTransition(updateFn).finished;
}
// 사용
transitionNavigate(() => {
router.push('/new-page');
});
// TypeScript에서 타입 안전하게
function startViewTransitionSafe(
callback: () => void | Promise<void>
): Promise<void> {
if (!('startViewTransition' in document)) {
const result = callback();
return result instanceof Promise ? result : Promise.resolve();
}
return (document as Document & {
startViewTransition: (cb: () => void | Promise<void>) => {
finished: Promise<void>;
};
}).startViewTransition(callback).finished;
}
| 브라우저 | SPA (JS API) | MPA (CSS/HTML) |
|---|---|---|
| Chrome 111+ | 지원 | - |
| Chrome 126+ | 지원 | 지원 |
| Edge 111+ | 지원 | - |
| Edge 126+ | 지원 | 지원 |
| Safari 18+ | 지원 | 지원 (실험적) |
| Firefox | 지원 예정 | 지원 예정 |
전 세계 사용자 기준으로 약 70-75%가 이미 지원하는 브라우저를 사용 중이야. Progressive enhancement 방식으로 구현하면 지금 당장 프로덕션에 적용해도 된다.
// 블로그 카드 클릭 → 포스트 상세 전환
interface Post {
id: string;
title: string;
coverImage: string;
slug: string;
}
function BlogCard({ post }: { post: Post }) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
// 클릭한 카드에 unique transition name 설정
const imgEl = document.getElementById(`post-img-${post.id}`);
const titleEl = document.getElementById(`post-title-${post.id}`);
if (imgEl) imgEl.style.viewTransitionName = 'post-cover';
if (titleEl) titleEl.style.viewTransitionName = 'post-title';
startViewTransitionSafe(() => {
window.location.href = `/blog/${post.slug}`;
});
};
return (
<article onClick={handleClick} style={{ cursor: 'pointer' }}>
<img
id={`post-img-${post.id}`}
src={post.coverImage}
alt={post.title}
/>
<h2 id={`post-title-${post.id}`}>{post.title}</h2>
</article>
);
}
// 상세 페이지에서 같은 transition name 사용
function BlogPost({ post }: { post: Post }) {
return (
<article>
<img
style={{ viewTransitionName: 'post-cover' }}
src={post.coverImage}
alt={post.title}
/>
<h1 style={{ viewTransitionName: 'post-title' }}>
{post.title}
</h1>
</article>
);
}
View Transitions API는 "JavaScript 없이도 네이티브 앱 같은 전환"이라는 꿈을 현실로 만들고 있어. 지금은 Progressive enhancement로 시작하되, 브라우저 지원이 더 넓어지면 디자인 시스템의 핵심 요소로 자리잡을 거야.