Chuyển đổi thành phần trong phạm vi

Khi bắt đầu chuyển đổi khung hiển thị bằng document.startViewTransition(), lớp phủ ::view-transition sẽ được đính kèm vào phần tử :root. Lớp phủ này chặn các sự kiện con trỏ trên phần còn lại của trang, ngăn chặn các quá trình chuyển đổi đồng thời và che khuất các tiêu đề position: fixed hoặc các phần tử [popover] đang mở (ngay cả khi các nhóm chuyển đổi khung hiển thị lồng nhau được dùng để ngăn chặn tình trạng tràn nội dung).

Việc gọi element.startViewTransition() trên một thành phần gốc sẽ giải quyết vấn đề này bằng cách đặt phạm vi chuyển đổi cho cây con DOM đó: cây phần tử giả ::view-transition được chèn trực tiếp vào phần tử, giữ nguyên bối cảnh xếp chồng bên ngoài và khả năng tương tác của trang, đồng thời cho phép nhiều thành phần chuyển đổi cùng lúc.

Triển khai

1. Kích hoạt hiệu ứng chuyển đổi trong phạm vi

Gọi element.startViewTransition() trên phần tử vùng chứa gốc của thành phần thay vì document.startViewTransition(). Phần tử mà bạn gọi startViewTransition() sẽ trở thành phạm vi của hiệu ứng chuyển đổi (phần tử phạm vi): chỉ các phần tử trong cây con của phần tử đó mới được ghi lại, chỉ quá trình kết xuất phạm vi mới tạm dừng trong lệnh gọi lại cập nhật và lớp phủ ::view-transition thu được sẽ khớp với kích thước, hình dạng và bối cảnh xếp chồng của phần tử phạm vi.

2. Đặt tên cho các phần tử tham gia

Chỉ định một view-transition-name cho các phần tử con bên trong phạm vi cần tạo ảnh động. Bạn có thể sử dụng view-transition-name: match-element trên các mục danh sách động để tự động lấy tên chuyển đổi của từng phần tử từ danh tính nút DOM hoặc chỉ định tên rõ ràng (chỉ cần là tên riêng biệt trong phạm vi đó).

Tên được tách biệt trong từng phạm vi, giúp ngăn chặn xung đột giữa các thành phần trong các phần khác nhau của DOM. Tuy nhiên, view-transition-name có thể xung đột với tên trong các hiệu ứng chuyển đổi khung hiển thị trong phạm vi lồng nhau. Để ngăn chặn điều này, hãy thêm view-transition-scope: all vào phần tử phạm vi con.

3. Tự tham gia và cắt bớt nội dung tràn

Phần tử phạm vi tự động tham gia vào quá trình chuyển đổi khung hiển thị của chính nó vì biểu định kiểu tác nhân người dùng áp dụng view-transition-name: root và view-transition-group: contain cho phần tử đó. Bằng cách này, khi hình học của phạm vi chịu ảnh hưởng của thay đổi mà bạn thực hiện trong DOM, phạm vi này sẽ tạo ảnh động mượt mà cho hình học của chính nó cùng với các phần tử con, trong quá trình Chuyển đổi khung hiển thị.

Khi phạm vi cắt bớt phần tràn (overflow: hidden, scroll hoặc clip), ::view-transition-group-children(root) sẽ tự động áp dụng overflow: clip, ngăn các phần tử con chuyển đổi tràn ra ngoài ranh giới thành phần.

4. Hiệu ứng chuyển đổi đồng thời và lồng nhau

Các cây con thành phần riêng biệt có thể chạy element.startViewTransition() đồng thời mà không bỏ qua nhau. Phạm vi đang hoạt động tự động nhận view-transition-scope: all trong biểu định kiểu UA để các giá trị view-transition-name nội bộ không bị rò rỉ vào các hiệu ứng chuyển đổi bên ngoài. Áp dụng view-transition-scope: all trong CSS trên các vùng chứa thành phần lặp lại để ngăn các hiệu ứng chuyển đổi bên ngoài thu thập tên nội bộ ngay cả khi thành phần ở trạng thái rảnh.

Nếu hiệu ứng chuyển đổi bên ngoài cần di chuyển một thành phần trong khi hiệu ứng chuyển đổi bên trong đang hoạt động, hãy chỉ định view-transition-name cho một phần tử bao bọc phạm vi bên trong. Sau đó, hiệu ứng chuyển đổi bên ngoài chỉ ghi lại và tạo ảnh động cho trình bao bọc thay vì phần tử phạm vi bên trong, cho phép hiệu ứng chuyển đổi bên trong chạy đến khi hoàn tất.

Ví dụ

Không cần CSS trên chính phần tử phạm vi (.card hoặc .card ul) để thiết lập phạm vi – phạm vi được xác định bằng cách gọi startViewTransition() trên phần tử thay vì document (nếu thành phần sẽ được lồng vào bên trong một hiệu ứng chuyển đổi khung hiển thị bên ngoài, hãy xem phần Chạy hiệu ứng chuyển đổi bên ngoài cho 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();
    }
  });
}

Chạy hiệu ứng chuyển đổi bên ngoài

Khi bắt đầu chuyển đổi khung hiển thị bên ngoài phạm vi dự kiến (ví dụ: từ một thành phần mẹ hoặc từ tài liệu cho quá trình chuyển đổi ở cấp trang), quá trình chuyển đổi bên ngoài sẽ ghi lại và tạo ảnh động cho phần tử phạm vi và mọi nội dung bên trong phần tử đó.

Để ngăn chặn điều này, hãy gói phần tử phạm vi trong một phần tử trình bao bọc riêng biệt có view-transition-name riêng và áp dụng view-transition-scope: all cho phần tử phạm vi bên trong. Điều này cho phép chính trình bao bọc được ghi lại và tạo ảnh động bằng hiệu ứng chuyển đổi bên ngoài, đồng thời ngăn nội dung của phạm vi bên trong bị ghi lại.

<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;
}

Ngoài ra, bạn có thể cho phép hiệu ứng chuyển đổi bên ngoài ghi lại các phần tử con. Để giữ lại hiệu ứng cắt, hãy sử dụng Nhóm chuyển đổi khung hiển thị lồng nhau để cắt các phần tử con đã chụp ở ranh giới của trình bao bọc. Tuy nhiên, trong trường hợp đó, bạn cần áp dụng clip theo cách thủ công cho phần tử giả ::view-transition-group-children() thu được.

.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;
}

Các quy tắc ràng buộc và hỗ trợ tiếp cận

  • BẮT BUỘC: Gọi startViewTransition() trên phần tử gốc của thành phần thay vì document để các phần tử position: fixed bên ngoài và lớp phủ [popover] vẫn được xếp lớp ở trên cùng và phần còn lại của trang vẫn có thể tương tác.
  • BẮT BUỘC: Giữ nguyên và di chuyển các nút DOM hiện có (thay vì tạo lại bằng innerHTML) khi sử dụng view-transition-name: match-element, vì match-element theo dõi danh tính của nút DOM.
  • BẮT BUỘC: Tôn trọng lựa chọn ưu tiên của người dùng về việc giảm chuyển động bằng cách sử dụng @media (prefers-reduced-motion: reduce) bằng cách đặt animation: none !important trên ::view-transition-group(*), ::view-transition-old(*) và ::view-transition-new(*).
  • BẮT BUỘC: Giữ tiêu điểm bàn phím khi xoá một phần tử được lấy tiêu điểm trong quá trình chuyển đổi bằng cách chuyển tiêu điểm sang một mục hoặc thành phần điều khiển liền kề bên trong lệnh gọi lại cập nhật.
  • NÊN: Sử dụng Nhóm chuyển đổi khung hiển thị lồng nhau (view-transition-group: contain và overflow: clip trên ::view-transition-group-children()) khi một phần tử được ghi lại trong phạm vi cắt bớt phần tràn của chính nó và chứa các phần tử được ghi lại lồng nhau.
  • NÊN: Đính kèm transition.ready.catch(() => {}) khi các hoạt động tương tác nhanh có thể kích hoạt lại element.startViewTransition() trên cùng một phần tử phạm vi.
  • NÊN: Gói một phần tử phạm vi bên trong vào một phần tử trình bao bọc riêng biệt có tên nếu quá trình chuyển đổi bên ngoài cần tạo ảnh động cho vị trí của thành phần trong khi quá trình chuyển đổi bên trong đang chạy.
  • KHÔNG: Đặt view-transition-name: none trên một phần tử phạm vi cắt bớt phần tràn (overflow: hidden, scroll hoặc clip) hoặc thay đổi hình học của chính phần tử đó, vì việc chọn không tự tham gia sẽ tắt tính năng tự động cắt bớt phần tràn.

Chiến lược dự phòng

Nếu Baseline của bạn không hỗ trợ hiệu ứng chuyển đổi khung hiển thị trong phạm vi phần tử, hãy hạ cấp một cách linh hoạt bằng cách chạy lệnh gọi lại cập nhật DOM ngay lập tức mà không có ảnh động. Mặc dù bạn có thể quay lại document.startViewTransition(), nhưng bạn không nên làm như vậy đối với các bản cập nhật thành phần riêng biệt, vì quá trình chuyển đổi trong phạm vi tài liệu sẽ chặn các quá trình chuyển đổi thành phần đồng thời và che khuất các phần tử position: fixed và [popover] ở lớp trên cùng.

// 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(() => {});
}

Nếu thực sự cần có một phương án dự phòng bằng document.startViewTransition(), bạn cần đảm bảo rằng hiệu ứng cắt của vùng chứa được giữ nguyên, như trình bày chi tiết trong phần Chạy hiệu ứng chuyển đổi bên ngoài. Xin lưu ý rằng phương pháp dự phòng này sẽ làm mất khả năng chạy nhiều hiệu ứng chuyển đổi khung hiển thị cùng một lúc và sẽ che khuất các phần tử position: fixed và [popover] ở lớp trên cùng.