CSS View Transitions API: 페이지 전환 애니메이션 완전 정복

프론트엔드

View Transitions API페이지 전환 애니메이션CSSSPA웹 애니메이션

이 글은 누구를 위한 것인가

  • 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 존재 확인으로 폴백 처리한다.