מעברים בין רכיבים מוגדרים

התחלת מעבר תצוגה באמצעות document.startViewTransition() מצרפת את שכבת-העל ::view-transition לרכיב :root, מה שחוסם אירועי סמן בשאר הדף, מונע מעברים בו-זמניים ומסתיר כותרות position: fixed או רכיבי [popover] פתוחים (גם כשמשתמשים בקבוצות מעבר תצוגה מקוננות כדי למנוע זליגת גלישה).

כדי לפתור את הבעיה הזו, קריאה ל-element.startViewTransition() ברכיב הבסיס מגדירה את היקף המעבר לעץ המשנה של ה-DOM: עץ הרכיבים המדומה ::view-transition מוזרק ישירות לרכיב, וכך שומר על הקשרי הערימה החיצוניים ועל האינטראקטיביות של הדף, ומאפשר מעבר של כמה רכיבים בו-זמנית.

הטמעה

1. הפעלת מעבר מוגדר

צריך להפעיל את element.startViewTransition() ברכיב הקונטיינר הבסיסי של הרכיב במקום את document.startViewTransition(). הרכיב שקוראים לו startViewTransition() הופך להיקף של המעבר (רכיב ההיקף): רק רכיבים בתוך עץ המשנה שלו נתפסים, רק העיבוד של ההיקף נעצר במהלך הקריאה החוזרת (callback) של העדכון, והשכבה שנוצרת ::view-transition תואמת לגודל, לצורה ולהקשר של רכיב ההיקף.

2. שמות הרכיבים המשתתפים

מקצים view-transition-name לאלמנטים משניים בתוך ההיקף שרוצים להוסיף להם אנימציה. אפשר להשתמש ב-view-transition-name: match-element בפריטים של רשימה דינמית כדי להפיק באופן אוטומטי את שם המעבר של כל רכיב מהזהות של צומת ה-DOM שלו, או להקצות שמות מפורשים (שצריכים להיות ייחודיים רק בהיקף הזה).

השמות מבודדים בתוך כל היקף, וכך נמנעות התנגשויות בין רכיבים בחלקים שונים של ה-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() בו-זמנית בלי לדלג אחד על השני. היקפים פעילים מקבלים באופן אוטומטי את הערך view-transition-scope: all בגיליון הסגנונות של UA, כך שערכי view-transition-name פנימיים לא זולגים למעברים חיצוניים. כדי למנוע ממעברים חיצוניים לתעד שמות פנימיים גם כשהרכיב לא פעיל, צריך להחיל את view-transition-scope: all ב-CSS על קונטיינרים של רכיבים חוזרים.

אם מעבר חיצוני צריך להזיז רכיב בזמן שהמעבר הפנימי שלו פעיל, מקצים view-transition-name לרכיב שעוטף את ההיקף הפנימי. המעבר החיצוני מתעד ומנפיש רק את רכיב העטיפה במקום את רכיב ההיקף הפנימי, וכך מאפשר למעבר הפנימי לפעול עד הסוף.

דוגמה

לא נדרש CSS ברכיב ההיקף עצמו (.card או .card ul) כדי לקבוע את ההיקף – ההיקף נקבע על ידי קריאה ל-startViewTransition() ברכיב במקום ל-document (אם הרכיב יהיה מוטמע בתוך מעבר תצוגה חיצוני, ראו הפעלת מעבר חיצוני עבור 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();
    }
  });
}

הפעלת מעבר חיצוני

כשמתחילים מעבר תצוגה מחוץ להיקף המיועד (למשל, מרכיב הורה או מהמסמך למעבר ברמת הדף), המעבר החיצוני יתעד את רכיב ההיקף ואת כל מה שבתוכו ויוסיף להם אנימציה.

כדי למנוע את זה, צריך לעטוף את רכיב ההיקף ברכיב wrapper נפרד עם view-transition-name משלו, ולהחיל את view-transition-scope: all על רכיב ההיקף הפנימי. כך אפשר לתעד את ה-wrapper עצמו ולהוסיף לו אנימציה באמצעות המעבר החיצוני, תוך מניעת תיעוד התוכן של ההיקף הפנימי.

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

לחלופין, אפשר לאפשר למעבר החיצוני לתעד את הצאצאים. כדי לשמור על אפקטים של חיתוך, צריך להשתמש בקבוצות של מעברי תצוגה בתצוגה מקוננת כדי לחתוך את הצאצאים שצולמו בגבולות של ה-wrapper שלהם. במקרה כזה, צריך להחיל באופן ידני את clip על ה-pseudo של ::view-transition-group-children() שנוצר.

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

אילוצים ונגישות

  • חובה: צריך להפעיל את startViewTransition() ברכיב הבסיס של הרכיב במקום את document, כדי שרכיבי position: fixed חיצוניים ושכבות-על של [popover] יישארו בשכבה העליונה ושאר הדף יישאר אינטראקטיבי.
  • חובה: כשמשתמשים ב-view-transition-name: match-element, צריך לשמור ולהעביר את צומתי ה-DOM הקיימים (במקום ליצור אותם מחדש באמצעות innerHTML), כי match-element עוקב אחרי הזהות של צומת ה-DOM.
  • חובה: צריך לכבד את העדפות המשתמשים לגבי הפחתת תנועה באמצעות @media (prefers-reduced-motion: reduce) על ידי הגדרת animation: none !important ב-::view-transition-group(*), ב-::view-transition-old(*) וב-::view-transition-new(*).
  • חובה: כדי לשמור על מיקוד המקלדת כשמסירים רכיב ממוקד במהלך מעבר, צריך להעביר את המיקוד לפריט או לפקד סמוך בתוך פונקציית ה-callback של העדכון.
  • מה כן לעשות: השתמשו בקבוצות של מעברי תצוגה בתצוגת עץ (view-transition-group: contain ו-overflow: clip ב-::view-transition-group-children()) כשרכיב שצולם בתוך ההיקף חותך את הגלישה שלו ומכיל רכיבים שצולמו בתצוגת עץ.
  • מומלץ: לצרף את transition.ready.catch(() => {}) כשאינטראקציות מהירות יכולות להפעיל מחדש את element.startViewTransition() באותו רכיב היקף.
  • מומלץ: לעטוף רכיב היקף פנימי ברכיב wrapper נפרד עם שם אם מעבר חיצוני צריך להוסיף אנימציה למיקום של הרכיב בזמן שמעבר פנימי פועל.
  • מה לא לעשות: אל תגדירו את view-transition-name: none ברכיב היקף שחותך את התוכן שחורג ממנו (overflow: hidden,‏ scroll או clip) או משנה את הגיאומטריה שלו, כי ביטול ההשתתפות העצמית משבית את החיתוך האוטומטי של התוכן שחורג.

אסטרטגיות חלופיות

אם יעד Baseline לא תומך במעברי תצוגה ברמת הרכיב, צריך להוריד את הרמה בצורה חלקה על ידי הפעלת הקריאה החוזרת (callback) של עדכון ה-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] בשכבה העליונה.