View Transitions API 실전 가이드: 네이티브 앱 같은 페이지 전환 구현

프론트엔드

View Transitions API페이지 전환웹 애니메이션React프론트엔드

이 글은 누구를 위한 것인가

  • 네이티브 앱처럼 부드러운 페이지 전환을 웹에 구현하고 싶은 개발자
  • FLIP 애니메이션이나 복잡한 CSS 트랜지션 없이 공유 요소 전환이 필요한 팀
  • View Transitions API를 React/Next.js와 통합하는 방법이 필요한 엔지니어

들어가며

모바일 앱에서는 화면 전환이 자연스럽다. 카드를 탭하면 카드가 확장되어 상세 화면이 되고, 뒤로 가면 다시 수축한다. 웹에서 이런 경험을 만들려면 복잡한 JavaScript가 필요했다.

View Transitions API는 이를 브라우저 수준에서 해결한다. CSS 몇 줄과 짧은 JavaScript로 네이티브 앱 수준의 페이지 전환을 구현할 수 있다. Chrome 111부터 지원하고, Firefox와 Safari도 지원이 진행 중이다.

이 글은 bluefoxdev.kr의 웹 애니메이션 가이드 를 참고하고, View Transitions API 실전 구현 관점에서 확장하여 작성했습니다.


1. View Transitions API 기본 원리

[View Transitions 동작 원리]

1. document.startViewTransition() 호출
2. 브라우저가 현재 화면의 스크린샷 캡처
3. DOM 업데이트 실행 (콜백 내)
4. 새 화면 캡처
5. 구 화면 → 신 화면 크로스페이드 (기본)
   (또는 CSS로 커스터마이징)

[view-transition-name으로 요소 연결]
구 화면의 card-123 → 신 화면의 card-detail
자동으로 FLIP 애니메이션 적용

2. 기본 구현

2.1 MPA (Multi-Page Application)

<!-- CSS만으로 페이지 전환 활성화 (Chrome 126+) -->
<style>
  @view-transition {
    navigation: auto;  /* 자동 전환 활성화 */
  }
  
  /* 기본 크로스페이드 커스터마이징 */
  ::view-transition-old(root) {
    animation: slide-out 300ms ease-in;
  }
  
  ::view-transition-new(root) {
    animation: slide-in 300ms ease-out;
  }
  
  @keyframes slide-out {
    from { transform: translateX(0); opacity: 1; }
    to { transform: translateX(-30px); opacity: 0; }
  }
  
  @keyframes slide-in {
    from { transform: translateX(30px); opacity: 0; }
    to { transform: translateX(0); opacity: 1; }
  }
</style>

2.2 SPA (JavaScript)

// 기본 view transition
async function navigateTo(url: string) {
  if (!document.startViewTransition) {
    // 폴백: 그냥 업데이트
    await updateDOM(url);
    return;
  }
  
  // View Transition 시작
  const transition = document.startViewTransition(async () => {
    await updateDOM(url);
  });
  
  // 전환 완료 대기 (선택적)
  await transition.finished;
}

async function updateDOM(url: string) {
  const response = await fetch(url);
  const html = await response.text();
  const doc = new DOMParser().parseFromString(html, 'text/html');
  
  document.title = doc.title;
  document.getElementById('main')!.innerHTML = 
    doc.getElementById('main')!.innerHTML;
}

3. 공유 요소 전환 (Hero Animations)

카드 목록에서 상세 페이지로 이동할 때 카드가 확장되는 효과.

/* 목록 페이지의 각 카드 */
.product-card {
  view-transition-name: product-card-1;  /* 각 카드마다 고유 이름 */
}

/* 상세 페이지의 헤더 이미지 */
.product-detail-image {
  view-transition-name: product-card-1;  /* 동일한 이름 */
}

/* 공유 요소 전환 커스터마이징 */
::view-transition-group(product-card-1) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// React에서 동적 view-transition-name 적용
function ProductCard({ product }: { product: Product }) {
  return (
    <div
      className="product-card"
      style={{
        // 각 카드마다 고유한 view-transition-name
        viewTransitionName: `product-${product.id}`,
      }}
    >
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
    </div>
  );
}

function ProductDetailImage({ product }: { product: Product }) {
  return (
    <img
      src={product.image}
      alt={product.name}
      style={{
        // 목록의 카드와 동일한 이름 → 자동으로 연결
        viewTransitionName: `product-${product.id}`,
      }}
    />
  );
}

4. Next.js App Router 통합

// app/components/TransitionLink.tsx
'use client';

import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { startTransition } from 'react';

interface TransitionLinkProps {
  href: string;
  children: React.ReactNode;
  className?: string;
}

export function TransitionLink({ href, children, className }: TransitionLinkProps) {
  const router = useRouter();
  
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    
    if (!document.startViewTransition) {
      router.push(href);
      return;
    }
    
    document.startViewTransition(() => {
      // React의 startTransition과 함께 사용
      startTransition(() => {
        router.push(href);
      });
    });
  };
  
  return (
    <a href={href} onClick={handleClick} className={className}>
      {children}
    </a>
  );
}
// app/products/page.tsx
import { TransitionLink } from '@/components/TransitionLink';

export default function ProductsPage() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <TransitionLink key={product.id} href={`/products/${product.id}`}>
          <div
            className="card"
            style={{ viewTransitionName: `product-${product.id}` }}
          >
            <img src={product.image} alt={product.name} />
            <p>{product.name}</p>
          </div>
        </TransitionLink>
      ))}
    </div>
  );
}

// app/products/[id]/page.tsx
export default function ProductDetailPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <img
        src={product.image}
        alt={product.name}
        style={{ viewTransitionName: `product-${params.id}` }}
      />
      <h1>{product.name}</h1>
    </div>
  );
}

5. View Transitions Level 2 (신기능)

/* Level 2: CSS로 탐색 타입에 따른 다른 애니메이션 */
@view-transition {
  navigation: auto;
}

/* 앞으로 가기 */
@media (prefers-reduced-motion: no-preference) {
  html:active-view-transition-type(forward) {
    &::view-transition-old(root) {
      animation: slide-to-left 300ms both;
    }
    &::view-transition-new(root) {
      animation: slide-from-right 300ms both;
    }
  }
  
  /* 뒤로 가기 */
  html:active-view-transition-type(backward) {
    &::view-transition-old(root) {
      animation: slide-to-right 300ms both;
    }
    &::view-transition-new(root) {
      animation: slide-from-left 300ms both;
    }
  }
}
// Level 2: 전환 타입 지정
document.startViewTransition({
  update: () => updateDOM(),
  types: ['forward'],  // 'forward', 'backward', 'enter', 'exit' 등
});

6. 접근성과 성능

/* 모션 감소 설정 존중 (필수!) */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

/* 또는 모든 전환을 페이드로만 */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) {
    animation-duration: 0.01ms !important;
  }
}
// 폴백 전략
function withViewTransition(callback: () => void) {
  if (!document.startViewTransition) {
    callback();  // API 미지원 시 그냥 실행
    return;
  }
  
  // 사용자 설정 확인
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reducedMotion) {
    callback();
    return;
  }
  
  document.startViewTransition(callback);
}

7. 브라우저 지원 현황 (2026 기준)

View Transitions Level 1 (document.startViewTransition):
- Chrome/Edge: 111+ ✅
- Firefox: 128+ ✅
- Safari: 18.2+ ✅

@view-transition { navigation: auto } (MPA):
- Chrome: 126+ ✅
- Firefox: 진행 중
- Safari: 18.4+ 부분 지원

Level 2 (types, :active-view-transition-type):
- Chrome: 131+ ✅
- Firefox: 개발 중
- Safari: 개발 중

→ SPA에서는 JavaScript startViewTransition 안정적
→ MPA CSS 전환은 Progressive Enhancement로 적용

마무리

View Transitions API는 웹 애니메이션의 게임 체인저다. 이전에는 FLIP 애니메이션이나 복잡한 CSS 조합으로 구현하던 공유 요소 전환을 view-transition-name 하나로 구현할 수 있다.

지금 당장 적용해볼 수 있는 것:

  1. SPA 페이지 전환: document.startViewTransition으로 감싸기
  2. 공유 요소 전환: 목록-상세 페이지에 view-transition-name 부여
  3. Progressive Enhancement: 미지원 브라우저에서는 기본 동작으로 폴백

prefers-reduced-motion을 반드시 지원하는 것을 잊지 마라.