일관된 교차 문서 전환

문제

교차 문서 뷰 전환은 동일 출처 탐색 중에 두 페이지 간에 요소를 애니메이션 처리합니다. 브라우저는 이전 페이지의 스냅샷을 캡처하고, 탐색한 다음, 스냅샷에서 새 페이지로 애니메이션을 적용합니다. 새 페이지에서 스타일시트, 레이아웃 스크립트, 주요 DOM 요소와 같은 중요한 리소스의 로드가 완료되지 않은 경우 전환은 불완전하거나 스타일이 지정되지 않은 상태로 애니메이션됩니다. 이로 인해 요소가 잘못된 위치로 변형되거나, 애니메이션 중간에 콘텐츠가 재배치되거나, 전환이 완료된 후 대체 글꼴이 웹 글꼴로 깜박이는 등의 시각적 결함이 발생합니다.

해결 방법

새 페이지의 <head>에서 중요한 <link> 및 <script> 요소에 blocking="render"를 사용하고, <link rel="expect">를 사용하여 특정 DOM 요소가 파싱될 때까지 렌더링을 차단합니다. 이렇게 하면 새 페이지의 시각적 상태가 안정될 때까지 브라우저가 뷰 전환 애니메이션을 시작하지 않습니다. 브라우저는 백그라운드에서 HTML을 계속 파싱하며 페인팅만 지연됩니다.

구현 전략

  1. 필수: 두 페이지 모두에서 @view-transition CSS at-rule을 사용하여 교차 문서 뷰 전환을 선택합니다.
  2. 필수: 중요한 스타일시트가 <head>에 있는지 확인합니다. <head>의 스타일시트는 기본적으로 렌더링 차단입니다. 동적으로 삽입된 스타일시트에는 명시적인 blocking="render"가 필요합니다.
  3. 전환 애니메이션이 실행되기 전에 실행되어야 하는 <script> 요소 (예: 테마를 적용하거나 레이아웃에 영향을 미치는 스크립트)에는 blocking="render"를 사용합니다.
  4. <link rel="expect" href="#element-id" blocking="render">를 사용하여 스크롤 없이 볼 수 있는 부분의 콘텐츠가 파싱될 때까지 렌더링을 차단해야 합니다. 이는 모든 전환 유형에 적용됩니다. 전체 페이지 크로스페이드 (빈 페이지로 애니메이션 처리되지 않도록 함), 모프 애니메이션 (이름이 지정된 요소가 DOM에 존재하도록 함), 스크립트 종속 레이아웃 (스타일이 지정된 콘텐츠가 파싱되도록 함)이 여기에 해당합니다.
  5. 중요하지 않은 콘텐츠의 렌더링을 차단하지 마세요. 초기 표시 영역에 영향을 미치는 리소스 및 요소만 차단합니다. 너무 많은 콘텐츠를 차단하면 전환이 지연되고 인지된 성능이 저하됩니다.

구현 가이드

1단계: 교차 문서 뷰 전환 선택

필수: 소스 페이지와 도착 페이지 모두에 @view-transition at-rule이 포함되어야 합니다. 이러한 설정이 없으면 문서 간 전환이 발생하지 않습니다.

/*
  MANDATORY: Include this rule in every page that participates
  in cross-document view transitions.
  `navigation: auto` enables transitions for standard navigations
  (link clicks, form submissions, back/forward).
*/
@view-transition {
  navigation: auto;
}

/* MANDATORY Copy-Paste Safety: Disable cross-document view transitions for users requesting reduced motion */
@media (prefers-reduced-motion: reduce) {
  @view-transition {
    navigation: none;
  }
}

2단계: 중요한 스크립트가 실행될 때까지 렌더링 차단

전환 애니메이션이 실행되기 전에 <head>의 비차단 스크립트가 실행되어야 하는 경우 (예: 테마 클래스를 적용하거나 레이아웃에 영향을 미치기 위해) blocking="render"로 표시합니다. 이 기능이 없으면 전환이 이미 시작된 후에 async, defer 또는 type="module" 스크립트가 실행될 수 있습니다.

<head>
  <!--
    DO: Mark layout-critical scripts with blocking="render".
  -->
  <script type=module blocking="render">
    // Example: apply a stored theme before the page renders,
    // so the transition snapshot reflects the correct theme.
    document.documentElement.dataset.theme =
      localStorage.getItem('theme') || 'light';
  </script>
</head>

3단계: 주요 DOM 요소가 파싱될 때까지 렌더링 차단

<head>의 스타일시트와 blocking="render" 스크립트는 <head>가 완전히 처리되었음을 보장할 뿐입니다. <body> 콘텐츠가 파싱될 때까지 기다리지 않습니다. 추가 차단이 없으면 브라우저는 스크롤 없이 볼 수 있는 부분이 DOM에 존재하기 전에 새 페이지 스냅샷을 찍을 수 있으므로 빈 페이지 또는 부분적으로 렌더링된 페이지로 애니메이션되는 전환이 발생합니다.

<link rel="expect">는 특정 요소(id로 식별됨)가 파싱될 때까지 렌더링을 차단하여 이 문제를 해결합니다. href 값은 타겟 요소의 id 속성과 일치하는 프래그먼트 식별자 (예: #hero)여야 합니다. 해당 요소의 닫는 태그가 파싱되면 렌더링 차단이 해제됩니다. <link rel="expect">는 타겟 DOM 요소가 문서에 존재한다는 것만 보장하며, 외부 하위 리소스 (예: <img> 픽셀)가 다운로드되거나 디코딩될 때까지 기다리지 않습니다.

다음 시나리오에서는 모두 <link rel="expect">를 사용합니다.

사용 사례 1: 전체 페이지 크로스페이드

개별 요소에 view-transition-name가 없는 경우에도 기본 root 전환은 전체 페이지를 크로스페이드합니다. 스크롤 없이 볼 수 있는 부분의 콘텐츠가 파싱되기 전에 새 페이지의 스냅샷이 생성되면 크로스페이드가 이전 페이지에서 빈 페이지 또는 불완전한 페이지로 애니메이션됩니다. 스크롤 없이 볼 수 있는 콘텐츠의 끝을 표시하는 요소에서 렌더링을 차단합니다.

<head>
  <link rel="stylesheet" href="/css/styles.css">

  <!--
    DO: Block rendering until the main content area is parsed,
    even for a simple cross-fade. Without this, the browser may
    snapshot the page before visible content exists in the DOM,
    causing the cross-fade to reveal a blank or partial page.
  -->
  <link rel="expect" href="#main-content" blocking="render">
</head>
<body>
  <header>...</header>
  <main id="main-content">
    <h1>Page Title</h1>
    <p>Above-the-fold content the user should see immediately.</p>
  </main>
  <!-- Content below the fold does NOT need to be blocked on -->
  <section>...</section>
</body>

사용 사례 2: 특정 요소 간의 변형 애니메이션

두 페이지의 요소가 view-transition-name를 공유하면 브라우저가 탐색 중에 요소를 부드럽게 변형합니다. 전환이 시작될 때 타겟 요소가 파싱되지 않은 경우 브라우저에서 이를 찾을 수 없으며, 모핑이 별도의 종료 및 시작 애니메이션으로 저하됩니다. view-transition-name가 있는 요소가 파싱될 때까지 렌더링을 차단합니다.

<link rel="expect">는 대상 DOM 요소가 파싱될 때까지만 렌더링을 일시중지하고 이미지 데이터가 다운로드되거나 디코딩될 때까지 기다리지 않으므로 캐시되지 않은 <img>가 새 페이지 스냅샷에서 빈 직사각형으로 캡처될 수 있습니다. 이미지 변형 중에 빈 스냅샷이나 레이아웃 변경이 발생하지 않도록 하려면 중요한 전환 이미지를 미리 로드하거나 미리 디코딩하고 (예: <link rel="preload" as="image">를 사용하거나 대상 페이지를 미리 렌더링) 명시적인 크기 조정 대체 (width 및 height 속성 또는 CSS aspect-ratio, 자리표시자 배경 색상)를 제공합니다.

<head>
  <link rel="stylesheet" href="/css/styles.css">

  <!--
    DO: Preload critical transition images so their pixels are
    available to decode before the new-page snapshot is captured.
    <link rel="expect"> only waits for the DOM element to be parsed,
    NOT for image pixels to download or decode.
  -->
  <link rel="preload" as="image" href="/img/product.webp" fetchpriority="high">

  <!--
    DO: Block rendering until the element participating in the
    morph animation has been parsed. Without this, the browser
    may start the transition before #hero exists, causing the
    morph to degrade to a fade-out/fade-in.
  -->
  <link rel="expect" href="#hero" blocking="render">

  <!--
    When multiple blocking="render" resources are present,
    rendering is blocked until ALL of them are satisfied.
    Here, the browser waits for both the script to execute
    AND the #hero element to be parsed — whichever comes last.
  -->
  <script async blocking="render" src="/js/transition-setup.js"></script>
</head>
<body>
  <header>...</header>
  <section id="hero">
    <h1 style="view-transition-name: page-title">Product Name</h1>
    <!--
      DO: Provide explicit dimensions (width/height or aspect-ratio)
      as a sizing fallback so the snapshot captures the correct layout
      box even if image decoding is still in progress.
    -->
    <img
      style="view-transition-name: hero-image"
      src="/img/product.webp"
      width="800"
      height="600"
      alt="Product"
    >
  </section>
</body>

thought 4단계: 반응형 렌더링 차단에 미디어 쿼리 사용하기

표시 영역 크기에 따라 스크롤 없이 볼 수 있는 부분에 표시되는 콘텐츠의 양이 다를 수 있습니다. <link rel="expect">에서 media 속성을 사용하여 특정 표시 영역 너비에 표시되는 콘텐츠에 대해서만 렌더링을 차단합니다.

<head>
  <!--
    DO: Use media queries to conditionally block rendering.
    On wide screens, both the hero and the sidebar are visible,
    so block until both are parsed. On narrow screens, only the
    hero is visible initially.
  -->
  <link
    rel="expect"
    href="#hero"
    blocking="render"
    media="screen and (width <= 768px)"
  >
  <link
    rel="expect"
    href="#sidebar"
    blocking="render"
    media="screen and (width > 768px)"
  >
</head>

5단계: 컨텍스트 종속 전환에 pagereveal 사용 (선택사항)

pagereveal 이벤트는 핵심 렌더링 차단 전략에 필요하지 않습니다. 사용자가 탐색한 위치에 따라 view-transition-name 값을 동적으로 할당해야 하는 경우에만 필요합니다(예: 특정 목록 항목을 세부정보 페이지 제목으로 변환).

view-transition-name 값이 CSS에서 정적으로 할당되거나 기본 전체 페이지 크로스페이드만 사용하는 경우 이 단계를 완전히 건너뜁니다.

<head>
  <!--
    MANDATORY: The pagereveal listener must be registered before
    the page renders. Inline small setup scripts with blocking="render"
    to avoid an extra network round-trip on cold loads, or use an
    external async script with blocking="render" so the listener is
    registered early without blocking HTML parsing.
    If the listener is registered too late (e.g., in a deferred
    script), the event may have already fired.
  -->
  <script async blocking="render" src="/js/transition-setup.js"></script>
</head>
// transition-setup.js
window.addEventListener('pagereveal', async (event) => {
  if (!event.viewTransition) return;

  const from = navigation.activation?.from;
  if (!from) return;

  const fromUrl = new URL(from.url);

  // DO: Assign view-transition-name based on navigation context.
  // This enables a morph animation from the product card on the
  // list page to the heading on the detail page.
  if (fromUrl.pathname === '/products/') {
    const heading = document.querySelector('main h1');
    if (!heading) return;

    heading.style.viewTransitionName = 'product-title';

    try {
      await event.viewTransition.ready;
    } catch (error) {
      // Skipped view transitions reject `ready` with InvalidStateError.
      if (error.name !== 'InvalidStateError') {
        throw error;
      }
    } finally {
      // MANDATORY: Remove the temporary name after the transition
      // finishes. Stale names interfere with subsequent navigations
      // and prevent the page from entering the bfcache.
      await event.viewTransition.finished;
      heading.style.viewTransitionName = '';
    }
  }
});

권장사항

  • 가능하면 CSS를 통해 view-transition-name를 할당해야 합니다. 이름이 탐색 컨텍스트에 따라 달라지는 경우를 위해 JavaScript 할당(pagereveal를 통해)을 예약합니다.
  • 렌더링 차단 스크립트를 작고 빠르게 유지합니다. 브라우저에는 제한 시간 (약 4초)이 내장되어 있으며, 이 시간이 지나면 전환이 완전히 건너뛰어집니다 (전환이 시작된 경우 InvalidStateError로 viewTransition.ready 거부).
  • 초기 표시 영역에 표시되지 않는 페이지 깊숙한 곳에 있는 요소에서 차단하는 데 <link rel="expect">를 사용하지 마세요. 이로 인해 시각적 이점 없이 전환이 지연됩니다.
  • 분석, 광고 네트워크, 태그 관리자 또는 기타 서드 파티 로더가 초기 표시 영역에 콘텐츠를 삽입하더라도 blocking="render"로 표시하지 마세요. 네트워크 지연 시간은 전환 제한 시간에 포함되며 출력은 페이지의 안정적인 상태에 포함되지 않습니다.
  • 동일한 페이지의 여러 요소에 동일한 view-transition-name를 할당하지 마세요. 이름이 중복되면 전체 전환이 건너뛰어집니다.
  • 보조 기술 타이밍 영향: blocking="render"를 사용하면 시각적 업데이트와 초기 페인트가 지연됩니다. 이렇게 하면 시력이 있는 사용자의 시각적 결함은 방지되지만, 렌더링된 접근성 트리에 의존하는 스크린 리더 및 기타 보조 기술의 경우 처리 지연 시간이나 초기화 지연이 발생할 수 있습니다. 시각적 연속성의 이점과 시각 장애가 있는 사용자의 초기 읽기 지연 시간을 비교하여 렌더링 차단 스크립트가 최소화되고 극도로 최적화되도록 합니다.

대체 전략

교차 문서 뷰 전환은 점진적 개선에 적합합니다. 이를 지원하지 않는 브라우저에서는 @view-transition 규칙이 무시되고 표준 동일 출처 탐색이 이 기능이 없는 경우와 동일하게 발생합니다. 지원되는 브라우저에서는 부드러운 전환이 가능하고, 그 외의 브라우저에서는 표준 탐색이 가능합니다. 제한된 브라우저 지원은 채택을 피할 이유가 되지 않습니다.

교차 문서 뷰 전환을 지원하는 모든 브라우저는 blocking="render" 및 <link rel="expect">도 지원하므로 이 가이드에 설명된 렌더링 차단 기능에 별도의 대체가 필요하지 않습니다.

기타 고려사항

  1. 성능 영향: 모든 렌더링 차단 리소스는 뷰 전환 애니메이션 시작을 지연시킵니다. 렌더링 차단 스크립트의 수를 최소화하고 스크롤 없이 볼 수 있는 부분에 있는 요소에만 <link rel="expect">를 사용합니다. Speculation Rules API를 사용하여 도착 페이지를 사전 렌더링하여 로드 지연을 완전히 없앱니다.
  2. 시간 제한 동작: 결합된 렌더링 차단 시간이 약 4초를 초과하면 브라우저는 전환을 건너뜁니다 (활성 viewTransition.ready 프로미스는 InvalidStateError로 거부됨). 이 기간 내에 중요한 리소스가 제대로 로드되는지 확인하세요.
  3. bfcache 호환성: 전환 후 삭제되지 않은 임시 view-transition-name 할당으로 인해 페이지가 bfcache에 들어가지 못할 수 있습니다. finished 콜백에서 동적으로 할당된 이름을 항상 삭제합니다.