동일 문서 전환

웹 기능: 뷰 전환

문제

웹사이트에서는 객체의 여러 뷰를 제공하는 경우가 많습니다. 예를 들어 제품 목록을 제공한 다음 각 제품의 세부정보 페이지를 제공합니다. 두 뷰 사이를 탐색할 때 연결이 끊어진 것처럼 느껴지는 경우가 많습니다. 사용자가 제품 썸네일을 클릭하여 세부정보를 확인하면 썸네일이 사라지고 화면의 다른 위치에 더 큰 새 이미지가 즉시 표시됩니다. 이러한 연속성 부족으로 인해 사용자가 요소 간의 관계를 추적하기가 더 어려워집니다.

해결 방법

View Transitions API를 사용하면 전환 전후에 서로 다른 상태로 존재하는 요소 쌍을 지정할 수 있습니다. 단일 페이지 탐색 (SPA)에서 document.startViewTransition()를 사용하여 전환을 트리거하면 브라우저는 공유된 고유한 view-transition-name를 통해 이러한 공유 요소를 식별합니다. 그런 다음 위치, 크기, 스타일의 차이를 자동으로 계산하고 이전 상태에서 새 상태로 부드럽게 애니메이션을 적용합니다. 이 전환은 z-index 값이 높은 요소보다도 위에 있는 최상위 레이어에서 발생합니다.

구현 가이드

1단계: startViewTransition에서 상태 변경 래핑

싱글 페이지 애플리케이션 (SPA) 또는 간단한 상태 변경의 경우 DOM을 업데이트하는 로직을 document.startViewTransition로 래핑합니다. 브라우저는 현재 상태의 스냅샷을 캡처하고 업데이트를 실행한 다음 새 상태를 캡처합니다.

function navigate(view) {
  // MANDATORY: Wrap the update in startViewTransition
  document.startViewTransition(() => updateDOM(view));
}

2단계: 공유 전환 이름 할당

view-transition-name CSS 속성을 사용하여 브라우저에 변형할 요소를 알려줍니다. 이름은 none를 제외하고 무엇이든 가능합니다. 필수: 지정된 view-transition-name 앞뒤에 요소가 1개 이하여야 합니다. 지정된 view-transition-name가 있는 요소가 2개 이상인 경우 DOM은 전환 없이 즉시 새 상태로 업데이트됩니다.

여러 view-transition-name를 사용하여 여러 요소 쌍을 변형할 수 있습니다. 예를 들어 제품 이미지와 제목을 별도의 전환으로 전환할 수 있습니다.

목록 보기에는 여러 항목이 있으므로 모든 항목에 동일한 view-transition-name를 지정할 수 없습니다. 이 문제는 SPA에서 두 가지 방법으로 해결할 수 있습니다.

  1. 동적 세부정보 페이지: 목록 페이지의 각 항목에 고유한 view-transition-name를 할당한 다음, 목록 항목을 선택할 때 세부정보 페이지의 일치하는 요소에 해당 이름을 동적으로 적용합니다(여기에 표시된 대로).

    /* In the list view, give each */
    #product-1 { view-transition-name: p1 }
    #product-2 { view-transition-name: p2 }
    #product-3 { view-transition-name: p3 }
    
    function updateDOM(clickedTransitionName){
      const hero = document.getElementById("hero");
      hero.style.viewTransitionName = clickedTransitionName;
    }
    
  2. 동적 목록 항목: 세부정보 페이지의 요소에 view-transition-name를 할당하고, 목록 페이지의 항목이 선택되면 해당 이름을 적용합니다. 목록 페이지로 돌아갈 때 목록 페이지의 항목에서 view-transition-name를 삭제합니다.

세부정보 페이지의 #hero 요소와 목록 페이지의 선택된 .thumbnail 요소는 view-transition-name를 공유합니다.

#hero{
  view-transition-name: hero;
}
.thumbnail.selected {
  view-transition-name: hero;
}

썸네일을 클릭하면 .selected 클래스 선택기를 사용하여 view-transition-name를 할당하고 전환을 시작하기 전에 DOM을 변경하여 목록 보기를 준비해야 합니다.

그런 다음 document.startViewTransition()를 호출하고 변경사항을 적용하여 페이지를 세부정보 보기에서 목록 보기로 전환할 수 있습니다.

목록 보기로 다시 이동한 후에는 다음 탐색에서 오류가 발생하지 않도록 뷰 전환 클래스를 정리해야 합니다. 전환의 finished 프로미스가 해결된 후 이 정리를 실행할 수 있습니다.

// Function called when a thumbnail is clicked
function goFromListToDetail(e){
  e.currentTarget.classList.add("selected");
  const hero = document.getElementById("hero");
  const bgColor = getComputedStyle(e.currentTarget).backgroundColor;
  hero.style.background = bgColor;

  // Trigger the transition, checking for support
  if (!document.startViewTransition) {
    document.body.classList.add("detail");
    // MANDATORY Accessibility Routing: Route focus to the newly revealed heading to announce context and preserve logical tab flow
    document.getElementById("detail-heading")?.focus();
    return; // MANDATORY: End function execution if view transitions are not supported.  
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.add("detail");
  });
  // MANDATORY Accessibility Routing: Route focus after the view transition resolves
  transition.finished.finally(() => {
    document.getElementById("detail-heading")?.focus();
  });
}

// Function called when navigating from detail back to list view
function goFromDetailToList() {
  if (!document.startViewTransition) {
    document.body.classList.remove("detail");
    document.getElementById("list-heading")?.focus();
    return;
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.remove("detail");
  });
  // Clean up the list view and route focus
  transition.finished.finally(() => {
    // Route focus back to list view
    document.getElementById("list-heading")?.focus();
    // Remove selected classList to remove view-transition-names
    document.querySelectorAll(".selected").forEach(
      (element) => {
        element.classList.remove("selected");
      },
    );
  });
}

선택하는 방법은 사용 사례에 따라 다릅니다. 동적 목록 항목은 반복되는 CSS가 적지만 수동 JavaScript 정리가 더 많이 필요합니다.

3단계: 가로세로 비율 '늘이기' 수정

기본적으로 브라우저는 두 스냅샷에 맞게 확장되는 그룹 내에서 이전 스냅샷과 새 스냅샷을 크로스페이드합니다. 텍스트를 전환하는 경우 전환된 요소의 가로세로 비율이 안정적이도록 이전 뷰와 새 뷰 모두에서 텍스트 요소의 너비를 fit-content로 설정합니다.

#list-page .title {
  width: fit-content;
}

#detail-page #title {
  width: fit-content;
}

가로세로 비율이 변경되는 요소를 전환하는 경우 이전 및 새 가상 요소의 높이를 ::view-transition-pair() 가상 요소의 100% 로 설정해야 할 수 있습니다.

::view-transition-old(hero),
::view-transition-new(hero){
  height: 100%;
}

의사 요소는 라이브 요소의 스냅샷이므로 object-fit 및 object-position 선언을 사용하여 전환 효과를 더 세밀하게 제어할 수도 있습니다.

권장사항

  • 전환을 너무 많이 지정하지 마세요. 사용자가 적극적으로 추적하는 기본 콘텐츠 (예: 히어로 이미지, 제목)에만 공유 요소를 사용합니다.
  • 권장사항: 전환이 완료된 후 임시 view-transition-name 값을 삭제하여 향후 전환에 부작용이 발생하지 않도록 합니다.
  • 활성 애니메이션이 있는 요소는 전환하지 마세요. 뷰 전환은 스냅샷에서 작동하므로 뷰 전환 중에 애니메이션이 일시중지된 것처럼 보입니다.
  • prefers-reduced-motion 미디어 쿼리를 사용하여 모션 감소에 대한 사용자 환경설정을 존중해야 합니다(DO).
  • 필수 접근성 라우팅: 뷰 전환은 페이지 레이아웃을 동적으로 변형하지만 프로그래매틱 포커스를 관리하지는 않습니다. 전환 중에 숨겨지거나 삭제된 요소에 포커스가 남아 있으면 포커스가 사라져 키보드 및 보조 기술 사용자에게 컨텍스트가 제공되지 않습니다. DOM 업데이트 직후 또는 뷰 전환의 finished 프로미스가 해결될 때 업데이트된 페이지 제목 또는 뷰 컨테이너로 프로그래매틱 방식으로 포커스를 이동합니다(tabindex="-1" 사용).
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

대체 전략

View Transitions API는 점진적 개선을 위해 설계되었습니다. 이를 지원하지 않는 브라우저는 애니메이션 없이 DOM 업데이트를 즉시 실행합니다.

function navigate(){
  if (!document.startViewTransition) {
    // Fallback: Just update the DOM
    updateDOM();
  } else {
    document.startViewTransition(() => updateDOM());
  }
}