CSS Cross-Document View Transitions 실전 가이드 — `@view-transition`과 `view-transition-name`으로 MPA에서 SPA 없이 자연스러운 페이지 전환 구현하기
Next.js나 Astro로 서비스를 만들다 보면 항상 같은 고민이 생깁니다. 사용자가 상품 카드를 클릭하는 순간 브라우저가 새 페이지를 로드하면서 화면이 하얗게 깜빡이는 그 순간 — MPA 아키텍처를 포기하기엔 SSR 성능이나 SEO 면에서 잃는 게 너무 많고, 그렇다고 그냥 두기엔 영 거슬립니다.
CSS View Transitions API Level 2가 크로스 도큐먼트(MPA) 내비게이션을 직접 지원하면서 이 문제에 대한 표준 해법이 생겼습니다.
다만 들어가기 전에 범위를 명확히 해둘 필요가 있습니다.
- JavaScript 없이 순수 CSS로 가능한 것: 전체 페이지 크로스페이드,
view-transition-name을 이용한 공유 요소 전환 - 소량의 JavaScript가 필요한 것: 앞으로/뒤로 방향성 슬라이드 (브라우저 히스토리 인덱스 비교가 필요합니다)
- 주의할 프레임워크 맥락: Next.js App Router와 SvelteKit는 기본적으로 클라이언트 사이드 내비게이션을 사용합니다. CSS
@view-transition은 실제 크로스 도큐먼트 내비게이션(전체 페이지 로드)이 발생할 때만 동작하므로, 이 두 프레임워크에서의 적용 범위는 제한적입니다. Astro는 기본 MPA 방식이라 가장 자연스럽게 맞아떨어집니다.
이 글에서는 @view-transition at-rule과 view-transition-name이 실제로 어떻게 동작하는지, Astro·SvelteKit·Next.js App Router 각각에서 어떻게 활용하는지, 그리고 실무에서 자주 만나는 함정들을 다뤄보겠습니다.
핵심 개념
브라우저가 전환을 처리하는 방식
Cross-Document View Transitions의 핵심은 브라우저가 두 장의 스냅샷을 찍고 그 사이를 보간한다는 겁니다. 기존 SPA에서 document.startViewTransition()을 직접 호출하는 방식과 달리, MPA에서는 @view-transition을 선언하면 브라우저가 내비게이션을 가로채서 자동으로 처리합니다.
네 단계입니다: 현재 페이지 스냅샷 → 새 문서 로드 → 새 페이지 스냅샷 → 두 스냅샷 사이 애니메이션. pageswap과 pagereveal은 방향성 전환을 구현할 때 중요하게 쓰이는데, 이는 뒤에서 자세히 다룹니다.
@view-transition으로 opt-in 선언하기
전환에 참여하려면 양쪽 문서 모두 @view-transition을 선언해야 합니다. 한쪽만 선언하면 전환이 발생하지 않습니다.
/* 글로벌 스타일시트에 한 번만 추가 */
@view-transition {
navigation: auto;
}navigation: auto는 사용자 제스처(링크 클릭, 폼 제출, 뒤로/앞으로 버튼)에 의한 내비게이션이면 자동으로 전환을 활성화한다는 의미입니다. 기본 동작은 전체 페이지 크로스페이드입니다.
동작 조건을 정리하면 이렇습니다.
| 조건 | 설명 |
|---|---|
| 동일 출처 | 같은 도메인 내 내비게이션만 지원, 서브도메인 간 이동 불가 |
| 양쪽 선언 | 출발 페이지와 도착 페이지 모두 @view-transition 필요 |
| 사용자 제스처 | 링크 클릭, 폼 제출, 브라우저 뒤로/앞으로 버튼 |
| 4초 타임아웃 | 새 페이지가 4초 내에 렌더 완료되어야 함 |
view-transition-name으로 공유 요소 전환 만들기
전체 페이지 크로스페이드도 충분히 좋지만, 진짜 강력한 기능은 **공유 요소 전환(Shared Element Transition)**입니다. 두 페이지에서 같은 이름을 가진 요소가 있으면, 브라우저가 위치·크기·비율을 자동으로 보간해서 한 요소가 다른 요소로 "날아가는" 효과를 만들어 줍니다.
/* 목록 페이지: 상품 썸네일 */
.product-thumbnail {
view-transition-name: product-hero;
}
/* 상세 페이지: 히어로 이미지 */
.product-hero-image {
view-transition-name: product-hero;
}이름만 맞춰주면 브라우저가 알아서 처리합니다. 다만 두 가지 제약이 있습니다.
이름 유일성: 같은 페이지 내에서 동일한 view-transition-name 값을 가진 요소가 두 개 이상 존재하면, 브라우저는 에러 없이 전환 전체를 취소합니다. 조용히 실패하기 때문에 동적 목록에서 특히 주의해야 합니다.
예약 값: none은 사용 불가합니다 (view-transition-name: none은 해당 요소를 전환에서 제외하는 의미입니다).
의사 요소 구조 이해하기
전환이 실행되는 동안 브라우저는 다음과 같은 의사 요소 트리를 생성합니다. 이 구조를 알면 CSS로 세밀하게 제어할 수 있습니다.
::view-transition-old(root)에만 애니메이션을 걸면 이전 페이지만 페이드아웃되고 새 페이지는 즉시 나타나는 식으로, 각 노드를 독립적으로 제어할 수 있습니다.
실전 적용
1. 전체 크로스페이드 — 가장 빠른 시작
/* globals.css 또는 공통 레이아웃 스타일 */
@view-transition {
navigation: auto;
}
/* 접근성: 움직임 감소 설정 존중 */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}prefers-reduced-motion 폴백은 처음부터 같이 넣어두는 게 낫습니다. 슬라이드나 스케일 전환은 움직임에 민감한 사용자에게 불편함을 줄 수 있는데, 기본 크로스페이드도 이 쿼리로 끄는 쪽을 선호하는 사용자가 있으니 처음부터 포함하는 게 좋은 습관입니다.
2. e-커머스 상품 목록 → 상세 페이지 공유 전환
동적으로 생성되는 카드 목록에서 가장 흔한 실수는 이름 충돌입니다. 클래스에 view-transition-name을 직접 쓰면 카드가 여러 개일 때 이름이 중복됩니다. 서버에서 각 카드에 고유 ID를 주입해야 합니다.
<!-- 목록 페이지 -->
<div class="product-grid">
<a href="/products/123">
<img
src="/products/123.jpg"
alt="상품명 A"
class="product-thumb"
style="view-transition-name: product-image-123"
>
</a>
<a href="/products/456">
<img
src="/products/456.jpg"
alt="상품명 B"
class="product-thumb"
style="view-transition-name: product-image-456"
>
</a>
</div><!-- 상세 페이지 /products/123 -->
<img
src="/products/123.jpg"
alt="상품명 A"
class="hero-image"
style="view-transition-name: product-image-123"
>/* 비율 왜곡 방지 */
/* 와일드카드로 모든 명명 요소에 공통 적용 */
::view-transition-old(*),
::view-transition-new(*) {
object-fit: cover;
}object-fit: cover 처리는 빼먹기 쉽지만 중요합니다. 의사 요소의 기본값이 fill이라, 썸네일(1:1 비율)이 상세 페이지 히어로 이미지(16:9 비율)로 전환될 때 이미지가 뭉개집니다. 여기서는 와일드카드 *로 전체 명명 요소에 공통 적용했는데, 요소마다 다른 애니메이션이 필요하다면 뒤에서 다룰 view-transition-class를 활용하면 됩니다.
3. 방향성 있는 슬라이드 전환 — pageswap·pagereveal 활용
앞으로/뒤로 이동할 때 서로 반대 방향으로 슬라이드하면 훨씬 자연스럽습니다. 여기서는 소량의 JavaScript가 필요합니다.
왜 JavaScript가 필요한가: :active-view-transition-type() 의사 클래스는 전환에 "타입"이 태깅되어 있어야 동작합니다. 브라우저가 방향을 자동으로 태깅해주지 않으므로, pagereveal(도착 페이지) 또는 pageswap(출발 페이지) 이벤트에서 직접 e.viewTransition.types.add()를 호출해야 합니다.
주의: 크로스 도큐먼트 MPA 전환에서 방향 타입을 지정하는 API는
pageswap·pagereveal이벤트의viewTransition.types입니다.document.startViewTransition()은 같은 문서 내 SPA 전환 API이므로 여기서는 동작하지 않습니다.
도착 페이지의 pagereveal 이벤트에서 처리하는 방법이 가장 단순합니다.
// 각 페이지의 공통 스크립트에 추가
window.addEventListener('pagereveal', (e) => {
if (!e.viewTransition) return;
const activation = navigation.activation;
const isBack =
activation?.navigationType === 'traverse' &&
(activation?.from?.index ?? 0) > (activation?.entry?.index ?? 0);
e.viewTransition.types.add(isBack ? 'back' : 'forward');
});navigation.activation.from.index(이전 페이지)와 navigation.activation.entry.index(현재 페이지)를 비교해 방향을 결정합니다. activation이 null일 수 있으므로 optional chaining을 반드시 사용해야 합니다.
출발 페이지에서 처리하는 방법도 있습니다.
// 출발 페이지에서 처리할 경우 (pageswap 이벤트)
window.addEventListener('pageswap', (e) => {
if (!e.viewTransition) return;
const isBack =
e.activation?.navigationType === 'traverse' &&
(e.activation?.from?.index ?? 0) > (e.activation?.entry?.index ?? 0);
e.viewTransition.types.add(isBack ? 'back' : 'forward');
});pageswap에서는 이벤트 객체 자체의 e.activation 속성을 사용합니다. 두 이벤트에서 설정한 타입은 전환 전체에서 공유됩니다. 어느 쪽에서 처리하든 :active-view-transition-type()이 올바르게 반응합니다.
CSS는 타입별 애니메이션을 지정합니다.
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); }
}
:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation: slide-out-left 0.3s ease;
}
&::view-transition-new(root) {
animation: slide-in-right 0.3s ease;
}
}
:active-view-transition-type(back) {
&::view-transition-old(root) {
animation: slide-out-right 0.3s ease;
}
&::view-transition-new(root) {
animation: slide-in-left 0.3s ease;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}4. Astro에서 네이티브 전환 (<ClientRouter /> 없이)
Astro는 기본 MPA 방식이라 CSS @view-transition이 가장 깔끔하게 동작하는 환경입니다. <ClientRouter />는 Astro 자체 SPA 모드를 활성화하므로, 네이티브 크로스 도큐먼트 전환과 함께 쓰면 충돌이 생길 수 있습니다.
---
// Layout.astro
---
<html lang="ko">
<head>
<meta charset="utf-8" />
<style>
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
</style>
</head>
<body>
<slot />
</body>
</html>공유 요소 전환은 각 페이지 컴포넌트에서 인라인 스타일로 이름을 직접 주입합니다.
---
// pages/products/[id].astro
const { product } = Astro.props;
---
<img
src={product.heroImage}
alt={product.name}
style={`view-transition-name: product-image-${product.id}`}
/>Astro의 transition:name 디렉티브는 <ClientRouter />에 의존하므로, 네이티브 방식에서는 CSS view-transition-name을 직접 적용해야 합니다.
5. SvelteKit에서 활용
SvelteKit는 기본적으로 클라이언트 사이드 내비게이션(SPA 방식)을 사용합니다. 이 경우 CSS @view-transition이 아니라 document.startViewTransition() API를 onNavigate 훅과 함께 씁니다.
SPA 모드 (기본 설정)
<!-- src/routes/+layout.svelte -->
<script>
import { onNavigate } from '$app/navigation';
onNavigate((navigation) => {
if (!document.startViewTransition) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
</script>
<slot />
<style>
@keyframes fade-in {
from { opacity: 0; }
}
@keyframes fade-out {
to { opacity: 0; }
}
:root::view-transition-old(root) {
animation: 200ms ease fade-out;
}
:root::view-transition-new(root) {
animation: 200ms ease fade-in;
}
@media (prefers-reduced-motion: reduce) {
:root::view-transition-old(root),
:root::view-transition-new(root) {
animation: none;
}
}
</style>이 방식은 SvelteKit의 클라이언트 라우터를 그대로 사용하므로, SSR 이점을 유지하면서도 전환 효과를 얻을 수 있습니다. 실용적으로는 이 SPA 모드 접근법이 SvelteKit에서 가장 자연스러운 선택입니다.
MPA 모드 (클라이언트 JS 비활성화)
특정 레이아웃에서 export const csr = false를 선언하거나 링크에 data-sveltekit-reload 속성을 추가하면 전체 페이지 로드가 발생하고, 이 경우 CSS @view-transition이 동작합니다.
<!-- src/routes/+layout.svelte (MPA 모드) -->
<style>
@view-transition {
navigation: auto;
}
</style>단, csr = false는 클라이언트 상태 관리, 리액티브 업데이트 등 SvelteKit의 핵심 기능을 잃게 됩니다. 특수한 경우가 아니라면 위의 SPA 모드 onNavigate 접근법을 권장합니다.
6. Next.js App Router에서의 활용
솔직히 말하면, Next.js App Router 환경에서 CSS @view-transition의 실질적인 활용 범위는 매우 제한적입니다. App Router의 <Link> 컴포넌트는 클라이언트 사이드 내비게이션을 수행하므로 크로스 도큐먼트 전환이 발생하지 않습니다.
@view-transition이 동작하는 경우는 다음으로 한정됩니다.
- 사용자가 URL을 직접 입력하거나 외부에서 진입하는 최초 페이지 로드
<a>태그를 직접 사용해 전체 페이지 로드를 유발하는 경우- 서버 사이드 폼 제출 등 하드 내비게이션이 발생하는 경우
이런 엣지 케이스에 전환 효과가 필요하다면 아래 CSS 자체는 의미가 있습니다. 다만 일반적인 <Link> 내비게이션에는 적용되지 않는다는 점을 분명히 인지하고 사용해야 합니다.
/* app/globals.css */
/* 주의: <Link> 컴포넌트를 통한 내비게이션에는 적용되지 않음 */
/* 전체 페이지 로드가 발생하는 경우에만 동작함 */
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}React 팀은 <ViewTransition> 컴포넌트를 실험 중이지만, App Router 클라이언트 라우터와의 완전한 통합까지는 시간이 더 필요합니다. 지금 당장 App Router에서 전환 효과가 필요하다면 swup 라이브러리를 View Transitions 플러그인과 함께 사용하는 방법을 검토해볼 만합니다.
7. view-transition-class로 다수 요소 처리 (Chrome 125+, Safari 18.2+)
상품 카드가 수십 개인 목록에서 이름마다 의사 요소 셀렉터를 따로 쓰면 CSS가 폭발합니다. view-transition-class는 이 문제를 해결하는 그룹화 도구입니다.
/* 카드마다 고유한 name은 유지하되, 공통 class를 추가 */
.card {
view-transition-class: card-item;
/* view-transition-name은 서버에서 인라인으로 주입: product-image-{id} */
}
/* 와일드카드 이름 + 클래스 형태로 선택 */
/* *.card-item: 어떤 이름이든 card-item 클래스를 가진 전환 요소 */
::view-transition-old(*.card-item),
::view-transition-new(*.card-item) {
animation-duration: 0.25s;
animation-timing-function: ease-out;
object-fit: cover;
}view-transition-class는 이름을 대체하지 않습니다. 각 카드는 여전히 고유한 view-transition-name이 필요하며, 이 클래스는 서로 다른 이름을 가진 요소들에 공통 스타일을 적용하기 위한 그룹화 메커니즘입니다.
셀렉터 문법에 대해 한 가지 주의할 점이 있습니다. ::view-transition-old(*.card-item) 형태(와일드카드 이름 * + 클래스)가 Level 2 스펙에서 의도한 문법입니다. 클래스만 단독으로 쓰는 ::view-transition-old(.card-item) 형태는 브라우저별 처리가 다를 수 있으니 와일드카드 형태를 사용하시기 바랍니다.
브라우저 지원 현황 및 장단점
브라우저 지원 현황
| 브라우저 | Same-Document | Cross-Document |
|---|---|---|
| Chrome | 111+ | 126+ |
| Edge | 111+ | 126+ |
| Safari | 18+ | 18.2+ |
| Firefox | 진행 중 | 진행 중 |
Firefox의 정확한 지원 버전은 빠르게 변하고 있습니다. 최신 수치는 caniuse — View Transitions API와 MDN View Transition API 브라우저 호환성 표에서 직접 확인하는 것이 가장 정확합니다.
점진적 향상이 기본 동작이라 미지원 브라우저에서는 일반 페이지 이동으로 자동 폴백됩니다. 도입 리스크가 낮은 편입니다.
장단점 한눈에 보기
| 구분 | 항목 | 내용 |
|---|---|---|
| 장점 | 최소 코드량 | 기본 크로스페이드는 CSS at-rule 하나로 완성 |
| 장점 | 점진적 향상 | 미지원 브라우저에서 일반 페이지 이동으로 자동 폴백 |
| 장점 | 브라우저 최적화 | GPU 합성 처리, JavaScript 구현 대비 성능 우수 |
| 장점 | 공유 요소 전환 | 위치·크기 보간을 브라우저가 자동 처리 |
| 단점 | 4초 타임아웃 | 느린 서버 응답 페이지에서 전환이 취소될 수 있음 |
| 단점 | 이름 유일성 강제 | 동일 이름 중복 시 전환 전체 취소, 무음 실패 |
| 단점 | 동일 출처 제한 | 서브도메인 간 이동, 외부 링크에 미적용 |
| 단점 | 메모리 비용 | 명명 요소마다 스냅샷 이미지 쌍이 메모리에 유지 |
실무에서 자주 만나는 실수
① view-transition-name 이름 중복
<!-- 잘못된 예: 모든 카드가 같은 이름 → 전환 전체 취소 -->
<img class="card-img" src="a.jpg"> <!-- CSS: view-transition-name: card; -->
<img class="card-img" src="b.jpg"> <!-- CSS: view-transition-name: card; -->
<!-- 올바른 예: 서버 렌더링 시 고유 ID 주입 -->
<img style="view-transition-name: card-42" src="a.jpg">
<img style="view-transition-name: card-99" src="b.jpg">에러 없이 그냥 전환이 안 되는 무음 실패입니다. Chrome DevTools Animations 패널에서 전환이 실행되지 않는다면 이름 충돌을 먼저 확인하세요.
② object-fit 생략으로 인한 이미지 왜곡
/* 비율이 다른 요소 간 전환 시 반드시 추가 */
::view-transition-old(*),
::view-transition-new(*) {
object-fit: cover; /* 기본값 fill이 왜곡 유발 */
}③ 느린 페이지에 공유 요소 전환 적용
서버 응답이 2~3초 걸리는 페이지에 공유 요소 전환을 걸면 4초 타임아웃에 걸려 전환이 취소됩니다. 느린 데이터 페이지에는 크로스페이드만 쓰거나, 로딩 스켈레톤을 빠르게 렌더해서 TTFB를 줄이는 방식이 현실적입니다.
④ prefers-reduced-motion 미처리
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}슬라이드, 스케일 등 움직임이 있는 전환에서 이 처리를 빠뜨리면 접근성 문제가 생깁니다.
⑤ MPA가 아닌 환경에서 @view-transition 적용 시도
@view-transition { navigation: auto; }는 실제 크로스 도큐먼트 내비게이션에서만 동작합니다. Next.js <Link>나 SvelteKit 기본 라우터처럼 클라이언트 사이드 내비게이션을 사용하는 환경에서는 이 CSS가 아무런 효과를 내지 않습니다.
프레임워크별 의사결정
마치며
지금 당장 시작하고 싶다면 이 순서를 권장합니다.
1단계 — 글로벌 CSS에 @view-transition { navigation: auto; }와 prefers-reduced-motion 폴백을 추가합니다. 진짜 MPA 내비게이션이 발생하는 환경(Astro, 또는 하드 내비게이션 상황)이라면 배포 즉시 크로스페이드가 동작합니다.
2단계 — 목록 → 상세 페이지 패턴에서 핵심 요소 하나에 view-transition-name을 붙여 공유 요소 전환을 시험해봅니다. 이름 중복 주의, object-fit: cover 추가.
3단계 — Chrome DevTools Animations 패널에서 전환 타임라인을 확인하고, 방향성 슬라이드가 필요하면 pagereveal 이벤트에서 타입을 태깅하고 :active-view-transition-type()으로 CSS를 확장합니다.
기본 크로스페이드는 순수 CSS로, 공유 요소 전환은 이름 하나로, 방향성 전환은 소량의 이벤트 핸들러로 완성됩니다. MPA를 유지하면서도 페이지 이동이 자연스러워지는 결과를 직접 확인해보시기 바랍니다.
참고 자료
- Cross-document view transitions for multi-page applications | Chrome for Developers
- View Transition API - MDN Web Docs
- @view-transition CSS at-rule - MDN
- Using the View Transition API - MDN
- CSS View Transitions Module Level 2 - W3C Working Draft
- What's new in view transitions (2025 update) | Chrome for Developers
- Cross-Document View Transitions: The Gotchas Nobody Mentions | CSS-Tricks
- Gotchas in Naming CSS View Transitions - Jim Nielsen's Blog
- View Transitions Supported in All Major Browsers | Bag of Tricks
- View transitions - Astro Docs
- ClientRouter or @view-transition: High-level Considerations | Bag of Tricks
- Dropping Astro's ClientRouter for web standards · Joost.blog
- cross-doc-explainer.md - WICG/view-transitions GitHub
- View transitions in Astro and SvelteKit - Pontus Horn
- View Transitions API | Can I use