← 목록으로 돌아가기

View Transitions API 실전: SPA 페이지 전환 애니메이션을 CSS와 JavaScript로 제어하는 방법

Web

View Transitions API SPA page transition animation CSS

페이지 전환, 여전히 깜박임으로 마무리되고 있지 않습니까

우리 프론트엔드 개발자들은 SPA를 만들면서 묘한 역설을 경험합니다. 서버 왕복 없이 화면을 바꿔도 사용자는 여전히 "뚝" 끊기는 느낌을 받습니다. React Router나 Next.js App Router가 라우팅 자체는 해결했지만, 이전 화면이 사라지고 새 화면이 나타나는 시각적 연속성은 개발자 각자가 Framer Motion, GSAP, 또는 CSS 클래스 교체로 구현해야 했습니다.

우리 팀이 이커머스 상세 페이지에서 Hero 이미지 공유 전환 애니메이션을 구현할 때가 그랬습니다. 목록 페이지의 썸네일이 상세 페이지의 대형 이미지로 자연스럽게 확장되어야 했는데, FLIP 애니메이션을 수작업으로 구현하다 보니 DOMRect 계산 코드, requestAnimationFrame 루프, cleanup 로직까지 합쳐 300줄이 넘었습니다. 그 코드를 브라우저 기본 기능 몇 줄로 대체할 수 있게 된 것이 View Transitions API입니다.

이 글에서는 View Transitions API의 동작 원리부터 CSS 슈도 엘리먼트 제어, 개별 요소 전환, 크로스-도큐먼트 MPA 전환, 접근성 처리, React Router와 Next.js App Router 통합까지 실무 관점에서 낱낱이 파헤칩니다. 공식 명세는 CSS View Transitions Module Level 1을 기준으로 합니다.


1. View Transitions API 개념: 왜 필요했는가

브라우저가 DOM을 업데이트할 때 기존에는 두 가지 상태 사이에 어떠한 시각적 다리도 없었습니다. 이전 상태는 즉시 사라지고 새 상태가 즉시 나타납니다.

View Transitions API는 브라우저가 이 작업을 대신합니다. document.startViewTransition()을 호출하면 브라우저는 현재 화면을 스냅숏으로 캡처합니다. 그런 다음 개발자가 제공한 콜백으로 DOM을 업데이트합니다. DOM 업데이트가 완료되면 브라우저는 새 화면을 두 번째 스냅숏으로 캡처합니다. 이 두 스냅숏 사이를 CSS 애니메이션으로 보간하는 것이 API의 핵심 메커니즘입니다.

Chrome Developers 공식 문서에 따르면 이 API는 두 가지 레벨로 나뉩니다. Level 1은 동일 문서(Same-Document) 전환으로 SPA에 적용되며, Level 2는 서로 다른 HTML 문서 사이의 크로스-도큐먼트(Cross-Document) 전환으로 MPA에서도 애니메이션을 제공합니다.

이 API가 기존 접근 방식보다 우월한 이유는 단순히 코드가 짧아서가 아닙니다. 브라우저가 스냅숏을 캡처하고 합성하는 과정을 GPU 레이어에서 처리하므로 메인 스레드 부담이 적고, 스크롤 위치나 iframe 콘텐츠처럼 개발자가 직접 복제하기 어려운 상태까지 정확하게 포착합니다.


2. document.startViewTransition() 기본 사용법

API의 진입점은 document.startViewTransition(updateCallback)입니다.

interface NavigateOptions {
  url: string;
  onBeforeSwap?: () => void | Promise<void>;
}

async function navigateWithTransition({ url, onBeforeSwap }: NavigateOptions): Promise<void> {
  if (!document.startViewTransition) {
    await onBeforeSwap?.();
    history.pushState({}, '', url);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await onBeforeSwap?.();
    history.pushState({}, '', url);
  });

  await transition.ready;
  console.log('[ViewTransition] 애니메이션 시작');

  await transition.finished;
  console.log('[ViewTransition] 애니메이션 완료');
}

ViewTransition 객체가 제공하는 세 가지 Promise: ready는 슈도 엘리먼트가 생성되어 CSS 애니메이션이 시작된 시점, finished는 모든 애니메이션이 끝난 시점, updateCallbackDone은 DOM 업데이트 콜백이 완료된 시점입니다.

skipTransition() 메서드도 중요합니다. 전환 도중 사용자가 새 내비게이션을 트리거하면 이전 전환을 즉시 완료하고 새 전환을 시작해야 합니다.


3. ::view-transition 슈도 엘리먼트와 ::view-transition-old/new

startViewTransition()이 호출되면 브라우저는 문서의 최상단에 ::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: 300ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

::view-transition-old(root) {
  animation-name: slide-out-left;
}

::view-transition-new(root) {
  animation-name: slide-in-right;
}

@keyframes slide-out-left {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(-30%); opacity: 0; }
}

@keyframes slide-in-right {
  from { transform: translateX(30%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

/* 뒤로가기 전환: 방향을 반대로 */
:root[data-nav-direction="back"]::view-transition-old(root) {
  animation-name: slide-out-right;
}

:root[data-nav-direction="back"]::view-transition-new(root) {
  animation-name: slide-in-left;
}

MDN의 View Transition API 레퍼런스에 따르면 ::view-transition 슈도 엘리먼트는 position: fixed, z-index: 2147483646으로 화면 최상단에 위치합니다.


4. view-transition-name으로 개별 요소 전환

기본 root 전환은 화면 전체를 하나의 스냅숏으로 처리합니다. 특정 요소가 두 화면 사이에서 공유된다면 view-transition-name CSS 속성을 사용합니다.

.product-detail__hero {
  view-transition-name: selected-product-image;
  contain: layout;
}
let currentTransition: ViewTransition | null = null;

function navigateToProduct(productId: string, clickedImageEl: HTMLImageElement): void {
  currentTransition?.skipTransition();

  clickedImageEl.style.viewTransitionName = 'selected-product-image';

  if (!document.startViewTransition) {
    clickedImageEl.style.viewTransitionName = '';
    router.push(`/products/${productId}`);
    return;
  }

  currentTransition = document.startViewTransition(async () => {
    await router.push(`/products/${productId}`);
  });

  currentTransition.finished.then(() => {
    clickedImageEl.style.viewTransitionName = '';
    currentTransition = null;
  });
}

view-transition-name을 사용할 때 가장 흔히 발생하는 실수는 동일한 name을 여러 요소에 동시에 적용하는 것입니다. 브라우저는 name이 중복되면 해당 요소의 개별 전환을 조용히 건너뜁니다.


5. 크로스-도큐먼트 전환(MPA View Transitions)

SPA가 아닌 전통적인 MPA에서도 View Transitions를 사용할 수 있습니다. CSS의 @view-transition 규칙을 양쪽 페이지에 선언하면 됩니다. MDN의 @view-transition 참조 문서에 따르면 이 at-rule은 Same-Origin 내비게이션에만 적용됩니다.

@view-transition {
  navigation: auto;
}

.article-thumbnail {
  view-transition-name: article-hero;
  contain: layout;
}

.article-hero {
  view-transition-name: article-hero;
  contain: layout;
}

::view-transition-group(article-hero) {
  animation-duration: 400ms;
  animation-timing-function: ease-in-out;
}

MPA 전환은 SPA가 제공하기 어려웠던 시나리오, 즉 완전히 다른 서버에서 생성된 HTML 페이지들 사이의 부드러운 전환을 가능하게 합니다. Next.js의 PPR(Partial Prerendering) 아키텍처와 결합하면 정적 셸이 즉시 표시되면서 View Transition이 실행되고, 동적 콘텐츠가 스트리밍되는 형태로 퍼포먼스와 UX를 동시에 잡을 수 있습니다.


6. 접근성: prefers-reduced-motion 처리

View Transitions API를 도입할 때 접근성 처리를 빠뜨리는 경우가 많습니다.

::view-transition-old(root) {
  animation: slide-out-left 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

::view-transition-new(root) {
  animation: slide-in-right 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
  }
}

CSS에서 처리하는 방식이 더 권장됩니다. API가 여전히 DOM 업데이트 콜백을 실행하므로 기능적으로는 동일하게 동작하고, 단지 시각적 전환만 억제됩니다.

전환 완료 후 포커스가 의미 있는 요소로 이동하도록 transition.finished.then(() => mainHeading.focus()) 패턴을 활용하는 것이 스크린 리더 사용자를 위한 최선의 실천입니다.


7. React Router와 통합 (unstable_ViewTransition)

React Router v7부터 unstable_ViewTransition 컴포넌트가 실험적으로 제공됩니다.

import { Link, unstable_useViewTransitionState } from 'react-router-dom';

function ProductCard({ product }) {
  const isTransitioning = unstable_useViewTransitionState(`/products/${product.id}`);

  return (
    <Link
      to={`/products/${product.id}`}
      viewTransition
      prefetch="intent"
    >
      <article>
        <img
          src={product.imageUrl}
          alt={product.name}
          style={{
            viewTransitionName: isTransitioning ? 'selected-product-image' : 'none',
            contain: 'layout',
          }}
        />
        <h2>{product.name}</h2>
      </article>
    </Link>
  );
}

unstable_useViewTransitionState 훅이 핵심입니다. 이 훅은 특정 경로로의 내비게이션이 View Transition 중일 때 true를 반환합니다.

unstable_ 접두사가 붙어 있다는 점을 실무에서 인지해야 합니다. API가 마이너 업데이트에서 변경될 수 있습니다.


8. Next.js App Router와 통합

Next.js App Router는 내부적으로 React의 startTransition으로 내비게이션을 처리하기 때문에 View Transitions API와 직접 통합하려면 별도 작업이 필요합니다.

React Server Components 아키텍처를 기반으로 하는 App Router에서는 useRouterpush 메서드를 래핑하는 방식이 가장 범용적입니다.

'use client';

import { useRouter } from 'next/navigation';
import { useCallback, useRef } from 'react';

export function useViewTransitionRouter() {
  const router = useRouter();
  const pendingTransitionRef = useRef<ViewTransition | null>(null);

  const push = useCallback(
    (...args: Parameters<ReturnType<typeof useRouter>['push']>) => {
      pendingTransitionRef.current?.skipTransition();

      if (!document.startViewTransition) {
        router.push(...args);
        return;
      }

      const transition = document.startViewTransition(() => {
        router.push(...args);
      });

      pendingTransitionRef.current = transition;

      transition.finished.finally(() => {
        pendingTransitionRef.current = null;
      });
    },
    [router]
  );

  return { ...router, push };
}

Next.js에서 <Link> 컴포넌트를 직접 수정하지 않고 <a> 태그로 교체하는 이유가 있습니다. <Link>는 클릭 이벤트를 내부에서 처리하고 router.push()를 직접 호출하기 때문에 startViewTransition을 끼워 넣을 진입점이 없습니다.


9. 진입·퇴장 애니메이션 타이밍 제어

/* 순차 전환: old가 나간 뒤 new가 들어오는 타이밍 설정 */
::view-transition-old(root) {
  animation: fade-out 200ms ease-out forwards;
}

::view-transition-new(root) {
  animation: fade-in 250ms ease-in 200ms backwards;
}

/* Shared Element 전환: 크기와 위치 보간에 커스텀 타이밍 적용 */
::view-transition-group(selected-product-image) {
  animation-duration: 450ms;
  animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* 새 콘텐츠 내 요소들의 stagger 진입 */
::view-transition-new(article-content) {
  animation: content-slide-up 350ms ease-out 100ms backwards;
}

::view-transition-new(article-meta) {
  animation: content-slide-up 350ms ease-out 200ms backwards;
}
전환 패턴지속 시간 권장값장점단점
크로스페이드150~250ms구현 단순시각적 연속성 없음
슬라이드250~350ms방향성 명확긴 페이지에서 어색함
Shared Element350~500ms맥락 보존JS/CSS 조율 복잡

300ms를 넘는 전환은 사용자가 "느리다"고 인식할 가능성이 높습니다.


10. 브라우저 지원 현황과 Fallback 전략

2026년 5월 기준 View Transitions API의 지원 현황입니다. Same-Document(Level 1)는 Chrome 111+, Edge 111+, Safari 18+, Opera 97+에서 지원됩니다. Firefox는 아직 Same-Document 전환을 지원하지 않으며 표준 추적 중입니다. Cross-Document(Level 2)는 Chrome 126+, Edge 126+에서 지원됩니다.

export function withViewTransition(updateFn: () => void | Promise<void>): Promise<void> {
  if (typeof document === 'undefined') {
    return Promise.resolve();
  }

  if (!('startViewTransition' in document)) {
    return Promise.resolve(updateFn()).then(() => undefined);
  }

  return document.startViewTransition(updateFn).finished;
}
@supports (view-transition-name: root) {
  ::view-transition-old(root) {
    animation: slide-out-left 280ms ease-out;
  }

  ::view-transition-new(root) {
    animation: slide-in-right 280ms ease-out;
  }
}

결론

View Transitions API는 "애니메이션 라이브러리를 줄이자"는 것이 아닙니다. 브라우저가 DOM 상태 전환의 시각적 연속성을 책임지게 함으로써, 개발자는 애니메이션 로직이 아니라 콘텐츠와 라우팅 로직에 집중하게 하는 아키텍처적 변화입니다.

  1. API Feature Detection 필수: Firefox, 구버전 Safari에서 Fallback으로 일반 DOM 업데이트가 즉시 실행되는지 반드시 확인합니다.
  2. view-transition-name 유일성 보장: 동일 문서에 같은 name을 가진 요소가 동시에 2개 이상 존재하면 해당 전환이 조용히 무시됩니다.
  3. prefers-reduced-motion 미디어 쿼리 처리.
  4. 전환 중첩 방지: currentTransition?.skipTransition()으로 이전 전환을 즉시 완료합니다.
  5. CSS 애니메이션 지속 시간 기준 준수.