Prologue: 네이티브 앱 같은 전환, 웹에서도 가능한가?
iOS 앱에서 카드를 탭하면 부드럽게 펼쳐지고, Android에서 화면이 슬라이드인되는 그 느낌. 웹에서 이걸 구현하려면 예전엔 JavaScript 애니메이션 라이브러리를 깔고, DOM 조작을 직접 하고, 성능 이슈랑 싸워야 했다.
Framer Motion이나 GSAP을 쓰면 어느 정도 되긴 했는데, 페이지 간 전환은 여전히 골치였다. SPA에서 라우트가 바뀔 때 이전 페이지가 사라지고 새 페이지가 나타나는 순간을 제어하려면, 상태 관리랑 애니메이션 타이밍을 맞추는 게 진짜 복잡해졌다.
그런데 브라우저 자체가 이 문제를 해결하겠다고 나섰다. View Transitions API다. 브라우저가 스크린샷을 찍고, DOM을 업데이트하고, 두 상태 사이를 CSS로 애니메이션하는 방식. 라이브러리 없이, 복잡한 상태 없이.
View Transitions API가 해결하는 문제
기존 방식의 한계
페이지 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)—카드가 상세 페이지로 펼쳐지는 효과—을 구현하려면 완전히 다른 접근이 필요했다.
View Transitions API의 핵심 아이디어
브라우저가 직접 개입한다. 동작 원리를 비유로 설명하면:
마치 영화 촬영에서 카메라가 한 장면을 찍고, 세트를 바꾼 다음, 다시 찍어서 모핑하는 것처럼.
- 현재 화면을 스크린샷 찍음 (
old상태) - DOM 업데이트 실행
- 새 화면을 스크린샷 찍음 (
new상태) - 두 스크린샷 사이를 CSS로 크로스페이드
이게 전부다. 나머지는 CSS ::view-transition 슈도 엘리먼트로 제어.
document.startViewTransition() 기본
가장 단순한 사용법
// DOM을 바꾸기 전에 이걸 감싸기만 하면 된다
document.startViewTransition(() => {
document.querySelector('#content').innerHTML = newContent;
});
기존 코드가 이랬다면:
document.querySelector('#content').innerHTML = newContent;
그냥 startViewTransition으로 감싸는 것만으로 자동으로 크로스페이드 애니메이션이 생긴다. 추가 CSS 없이.
비동기 DOM 업데이트
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('페이지 전환 완료');
}
transition 객체의 프로퍼티
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();
CSS ::view-transition 슈도 엘리먼트
슈도 엘리먼트 구조
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;
}
}
공유 요소 전환 (Shared Element Transition)
이게 진짜 킬러 피처다. 카드에서 상세 페이지로 이동할 때 카드 이미지가 부드럽게 커지는 효과.
view-transition-name 지정
/* 카드의 이미지에 고유 이름 부여 */
.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; }
React에서 구현하는 방법
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 vs SPA: 어디서 쓸 수 있나?
비교 표
| 구분 | MPA (Multi-Page App) | SPA (Single-Page App) |
|---|---|---|
| 지원 여부 | Chrome 126+에서 네이티브 지원 | 이미 지원 (JavaScript) |
| 설정 방법 | HTML meta 태그 또는 HTTP 헤더 | document.startViewTransition() |
| 복잡도 | 매우 쉬움 | 중간 |
| 공유 요소 전환 | CSS만으로 가능 | JS + CSS |
MPA에서의 설정
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 없이 페이지 전환 애니메이션이 생긴다.
MPA에서 공유 요소 전환
<!-- 목록 페이지 -->
<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에서의 구현
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 공식 지원 전망
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>
);
}
성능과 주의사항
view-transition-name은 페이지 내에서 유일해야 한다
/* 이렇게 하면 안 됨 - 같은 이름이 두 개면 전환이 안 됨 */
.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}`;
});
contain: layout 이슈
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 = '';
});
Progressive Enhancement 전략
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;
}
브라우저 지원 현황 (2026년 3월 기준)
| 브라우저 | 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>
);
}
정리: 언제 쓸까?
지금 당장 쓸 수 있는 경우:
- 크로스페이드 전환 (가장 단순, 모든 앱에 적용 가능)
- SPA의 라우트 전환
- 탭 전환, 모달 열기/닫기
- 아코디언 확장/축소
좀 더 공을 들여야 하는 경우:
- 공유 요소 전환 (카드 → 상세)
- MPA에서 특정 요소만 모핑
- 방향성 있는 전환 (뒤로 가기 다름)
아직은 기다리는 게 나은 경우:
- Firefox 사용자가 많은 서비스 (폴백 충분하지만)
- 매우 복잡한 공유 요소 전환이 핵심 기능인 경우
View Transitions API는 "JavaScript 없이도 네이티브 앱 같은 전환"이라는 꿈을 현실로 만들고 있어. 지금은 Progressive enhancement로 시작하되, 브라우저 지원이 더 넓어지면 디자인 시스템의 핵심 요소로 자리잡을 거야.