범위가 지정된 구성요소 전환

document.startViewTransition()로 뷰 전환을 시작하면 ::view-transition 오버레이가 :root 요소에 연결되어 페이지의 나머지 부분에서 포인터 이벤트를 차단하고, 동시 전환을 방지하며, position: fixed 헤더 또는 열려 있는 [popover] 요소를 가립니다 (중첩된 뷰 전환 그룹을 사용하여 오버플로 번짐을 방지하는 경우에도 마찬가지임).

구성요소 루트에서 element.startViewTransition()를 호출하면 전환 범위를 해당 DOM 하위 트리로 지정하여 이 문제를 해결합니다. ::view-transition 가상 요소 트리가 요소에 직접 삽입되어 외부 스택 컨텍스트와 페이지 상호작용을 유지하면서 여러 구성요소가 동시에 전환될 수 있습니다.

구현

1. 범위가 지정된 전환 트리거

document.startViewTransition() 대신 구성요소의 루트 컨테이너 요소에서 element.startViewTransition()를 호출합니다. startViewTransition()를 호출하는 요소가 전환의 범위 (범위 요소)가 됩니다. 하위 트리의 요소만 캡처되고 업데이트 콜백 중에 범위의 렌더링만 중지되며 결과 ::view-transition 오버레이가 범위 요소의 크기, 모양, 스택 컨텍스트와 일치합니다.

2. 참여 요소 이름 지정

애니메이션을 적용해야 하는 범위 내의 하위 요소에 view-transition-name를 할당합니다. 동적 목록 항목에 view-transition-name: match-element를 사용하여 DOM 노드 ID에서 각 요소의 전환 이름을 자동으로 파생하거나 명시적 이름 (해당 범위 내에서 고유해야 함)을 할당할 수 있습니다.

이름은 각 범위 내에서 격리되어 DOM의 다른 부분에 있는 구성요소 간의 충돌을 방지합니다. 그러나 view-transition-name는 중첩된 범위가 지정된 뷰 전환의 이름과 충돌할 수 있습니다. 이를 방지하려면 하위 범위 요소에 view-transition-scope: all를 추가합니다.

3. 자체 참여 및 오버플로우 클리핑

사용자 에이전트 스타일시트가 view-transition-name: root 및 view-transition-group: contain를 적용하므로 범위 요소는 자체 뷰 전환에 자동으로 참여합니다. 이렇게 하면 DOM에서 변경한 사항이 범위의 도형에 영향을 미칠 때 뷰 전환의 일부로 하위 요소와 함께 자체 도형에 원활하게 애니메이션을 적용합니다.

범위가 오버플로 (overflow: hidden, scroll 또는 clip)를 클립하면 ::view-transition-group-children(root)가 자동으로 overflow: clip를 적용하여 전환 중인 하위 요소가 구성요소 경계 외부로 번지지 않도록 합니다.

4. 동시 및 중첩 전환

별도의 구성요소 하위 트리는 서로 건너뛰지 않고 element.startViewTransition()를 동시에 실행할 수 있습니다. 활성 범위는 UA 스타일시트에서 자동으로 view-transition-scope: all를 수신하므로 내부 view-transition-name 값이 외부 전환으로 유출되지 않습니다. 반복되는 구성요소 컨테이너의 CSS에 view-transition-scope: all를 적용하여 구성요소가 유휴 상태일 때도 외부 전환이 내부 이름을 캡처하지 못하도록 합니다.

내부 전환이 활성 상태인 동안 외부 전환에서 구성요소를 이동해야 하는 경우 내부 범위를 래핑하는 요소에 view-transition-name를 할당합니다. 그러면 외부 전환은 내부 범위 요소 대신 래퍼만 캡처하고 애니메이션 처리하여 내부 전환이 완료될 때까지 실행될 수 있도록 합니다.

예

범위를 설정하기 위해 범위 요소 자체 (.card 또는 .card ul)에 CSS가 필요하지 않습니다. 범위는 document 대신 요소에서 startViewTransition()를 호출하여 결정됩니다(구성요소가 외부 뷰 전환 내에 중첩되는 경우 view-transition-scope: all에 관한 외부 전환 실행 참고).

/* 1. Name participating child items */
.card ul li {
  view-transition-name: match-element;
}

/* 2. OPTIONAL: Customize transition timing (300ms is an example duration) */
::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: ease-in-out;
}

/* 3. MANDATORY: Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
// 1. Trigger a transition scoped to a single component subtree
function runScopedTransition(scopeEl, updateDOM) {
  // MANDATORY: Call startViewTransition on the component element, not document
  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  // Re-triggering startViewTransition() on the same element skips the active transition
  // and rejects `ready`. Catch the rejection to avoid unhandled promise rejection errors.
  transition.ready.catch(() => {});
}

// 2. Reorder items by moving existing DOM nodes (required for `match-element`)
// Scope to `listEl` since only the list order changes, keeping controls outside `listEl` interactive
function reorderItems(listEl, orderedIds) {
  const existing = new Map(
    Array.from(listEl.children, (li) => [li.dataset.id, li]),
  );

  runScopedTransition(listEl, () => {
    for (const id of orderedIds) {
      const li = existing.get(id);
      if (li) listEl.appendChild(li);
    }
  });
}

// 3. Remove an item while preserving exit animation and keyboard focus
// Scope to the outer `cardEl` so the card itself also transitions its height change
function removeItem(cardEl, itemEl) {
  const hadFocus = itemEl.contains(document.activeElement);
  const nextFocusTarget =
    itemEl.nextElementSibling?.querySelector('button') ??
    itemEl.previousElementSibling?.querySelector('button') ??
    cardEl.querySelector('button');

  runScopedTransition(cardEl, () => {
    itemEl.remove();
    if (hadFocus) {
      nextFocusTarget?.focus();
    }
  });
}

외부 전환 실행

의도한 범위 외부에서 뷰 전환을 시작하는 경우 (예: 상위 구성요소 또는 페이지 수준 전환의 문서에서) 외부 전환은 범위 요소와 그 내부의 모든 항목을 캡처하고 애니메이션을 적용합니다.

이를 방지하려면 자체 view-transition-name가 있는 별도의 래퍼 요소에 범위 요소를 래핑하고 내부 범위 요소에 view-transition-scope: all를 적용합니다. 이렇게 하면 래퍼 자체가 외부 전환에 의해 캡처되고 애니메이션화되는 동시에 내부 범위의 콘텐츠가 캡처되지 않습니다.

<div class="page" id="page">
  <div class="card-wrapper">
    <section class="card" id="left">
      <h2>Card A</h2>
      <ul id="left-list">
        <li>…</li>
      </ul>
    </section>
  </div>
  <div class="card-wrapper">
    <section class="card" id="right">
      <h2>Card B</h2>
      <ul id="right-list">
        <li>…</li>
      </ul>
    </section>
  </div>
</div>
document.querySelector('#page').startViewTransition(() => {
  // do your DOM updates here...
});
/* Apply a view-transition-name on the wrapper */
.card-wrapper {
  view-transition-name: match-element;
}

/* Prevent the contents of the card being captured by an outer transition */
.card {
  view-transition-scope: all;
}

.card ul li {
  view-transition-name: match-element;
}

또는 외부 전환이 하위 요소를 캡처하도록 허용할 수 있습니다. 클리핑 효과를 유지하려면 중첩된 뷰 전환 그룹을 사용하여 래퍼 경계에서 캡처된 하위 요소를 클리핑합니다. 하지만 이 경우 결과 ::view-transition-group-children() pseudo에 clip를 수동으로 적용해야 합니다.

.card-wrapper {
  overflow: clip;
  view-transition-name: match-element; /* Capture me as part of the outer VT */
  view-transition-group: contain; /* Nest children that also have a view-transition-name */
  view-transition-class: card; /* For targeting purposes */
}

/* Manually copy back the clip onto the ::view-transition-group-children pseudo */
::view-transition-group-children(.card) {
  overflow: clip;
}

.card ul li {
  view-transition-name: match-element;
}

제약 조건 및 접근성

  • 필수: document 대신 구성요소 루트 요소에서 startViewTransition()를 호출하여 외부 position: fixed 요소와 [popover] 오버레이가 맨 위에 레이어로 유지되고 나머지 페이지가 상호작용 상태를 유지하도록 합니다.
  • 필수: view-transition-name: match-element를 사용할 때 기존 DOM 노드를 보존하고 이동합니다(innerHTML로 다시 만들지 않음). match-element는 DOM 노드 ID를 추적하기 때문입니다.
  • 필수: ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*)에서 animation: none !important를 설정하여 @media (prefers-reduced-motion: reduce)를 사용한 모션 감소에 대한 사용자 환경설정을 준수합니다.
  • 필수: 전환 중에 포커스가 있는 요소를 삭제할 때 업데이트 콜백 내에서 인접한 항목이나 컨트롤로 포커스를 이동하여 키보드 포커스를 유지합니다.
  • 권장사항: 범위 내의 캡처된 요소가 자체 오버플로를 클립하고 중첩된 캡처된 요소를 포함하는 경우 중첩된 뷰 전환 그룹 (::view-transition-group-children()의 view-transition-group: contain 및 overflow: clip)을 사용합니다.
  • 권장사항: 빠른 상호작용으로 동일한 범위 요소에서 element.startViewTransition()를 다시 트리거할 수 있는 경우 transition.ready.catch(() => {})를 연결합니다.
  • 권장사항: 내부 전환이 실행되는 동안 외부 전환에서 구성요소의 위치에 애니메이션을 적용해야 하는 경우 별도의 이름이 지정된 래퍼 요소에 내부 범위 요소를 래핑합니다.
  • 금지사항: 오버플로를 클리핑하거나 (overflow: hidden, scroll 또는 clip) 자체 도형을 변경하는 범위 요소에 view-transition-name: none를 설정하지 마세요. 자체 참여를 선택 해제하면 자동 오버플로 클리핑이 사용 중지되기 때문입니다.

대체 전략

기준 타겟이 요소 범위 뷰 전환을 지원하지 않는 경우 애니메이션 없이 DOM 업데이트 콜백을 즉시 실행하여 단계적으로 성능을 저하시킵니다. document.startViewTransition()로 대체할 수 있지만, 문서 범위 전환은 동시 구성요소 전환을 차단하고 최상위 레이어의 position: fixed 및 [popover] 요소를 가리기 때문에 격리된 구성요소 업데이트에는 권장되지 않습니다.

// Feature detect specifically on Element.prototype (not document.startViewTransition)
const supportsScopedTransition = 'startViewTransition' in Element.prototype;

function runWithTransition(scopeEl, updateDOM) {
  if (!supportsScopedTransition) {
    // Fallback: apply the DOM update immediately without animation
    updateDOM();
    return;
  }

  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  transition.ready.catch(() => {});
}

document.startViewTransition()를 사용하여 대체해야 하는 경우 외부 전환 실행 섹션에 설명된 대로 컨테이너의 클리핑 효과가 유지되는지 확인해야 합니다. 이 대체 접근 방식은 여러 뷰 전환을 동시에 실행하는 기능을 중단하고 최상위 레이어의 position: fixed 및 [popover] 요소를 가립니다.