מעברים של תצוגה באותו מסמך באפליקציות של דף יחיד

כשמעבר תצוגה פועל במסמך יחיד, הוא נקרא מעבר תצוגה באותו מסמך. לרוב זה המצב באפליקציות עם דף יחיד (SPA), שבהן נעשה שימוש ב-JavaScript כדי לעדכן את ה-DOM. מעבר בין תצוגות באותו מסמך נתמך ב-Chrome החל מגרסה 111.

כדי להפעיל מעבר לתצוגה אחרת באותו מסמך, צריך להפעיל את document.startViewTransition:

function handleClick(e) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow();
    return;
  }

  // With a View Transition:
  document.startViewTransition(() => updateTheDOMSomehow());
}

כשמפעילים את הפונקציה, הדפדפן מתעד באופן אוטומטי קובצי snapshot של כל האלמנטים שהוגדרה להם מאפיין CSS מסוג view-transition-name.

לאחר מכן, הוא מפעיל את פונקציית ה-callback שהועברה ומעדכן את ה-DOM. לאחר מכן, הוא יוצר קובצי snapshot של המצב החדש.

תמונות המצב האלה מסודרות לאחר מכן בעץ של פסאודו-רכיבים ומונפשות באמצעות העוצמה של אנימציות CSS. זוגות של תמונות מצב מהמצב הישן והחדש עוברים בצורה חלקה מהמיקום והגודל הישנים שלהם למיקום החדש שלהם, והתוכן שלהם הצטמצם באופן הדרגתי. אם רוצים, אפשר להשתמש ב-CSS כדי להתאים אישית את האנימציות.


מעבירת ברירת המחדל: מעבר הדרגתי

מעבר התצוגה שמוגדר כברירת מחדל הוא מעבר הדרגתי, ולכן הוא יכול לשמש כהקדמה טובה ל-API:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // With a transition:
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

כאשר updateTheDOMSomehow משנה את ה-DOM לסטטוס החדש. אפשר לעשות זאת בכל דרך שתרצו. לדוגמה, אפשר להוסיף או להסיר רכיבים, לשנות שמות של כיתות או לשנות סגנונות.

ובדיוק ככה, דפים הדרגתיים:

ההחלפה בהדרגה שמוגדרת כברירת מחדל. הדגמה מינימלית. מקור.

אוקיי, עמעום הדרגתי לא מרשים כל כך. למרבה המזל, אפשר להתאים אישית מעברים, אבל קודם כול צריך להבין איך הייתה הפעולה הבסיסית של עמעום הדרגתי.


איך המעברים האלה פועלים

נעדכן את דוגמת הקוד הקודמת.

document.startViewTransition(() => updateTheDOMSomehow(data));

כשמתבצעת קריאה אל .startViewTransition(), ה-API מתעד את המצב הנוכחי של הדף. למשל, צילום קובץ snapshot.

כשהתהליך יסתיים, תתבצע קריאה חוזרת (callback) אל .startViewTransition(). זה המקום שבו DOM משתנה. לאחר מכן, ה-API מתעד את המצב החדש של הדף.

אחרי תיעוד המצב החדש, ה-API יוצר עץ של רכיבי פסאודו-אלמנט באופן הבא:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

הקובץ ::view-transition נמצא בשכבת-על מעל לכל שאר הפריטים בדף. האפשרות הזו שימושית אם רוצים להגדיר צבע רקע למעבר.

::view-transition-old(root) הוא צילום מסך של התצוגה הישנה, ו-::view-transition-new(root) הוא ייצוג בזמן אמת של התצוגה החדשה. שניהם מוצגים כ'תוכן שהוחלף' ב-CSS (כמו <img>).

התצוגה הישנה מונפשת מ-opacity: 1 ל-opacity: 0, והתצוגה החדשה מונפשת מ-opacity: 0 ל-opacity: 1, וכך נוצרת מעבר הדרגתי.

כל האנימציה מתבצעת באמצעות אנימציות CSS, כך שניתן להתאים אותן אישית באמצעות CSS.

התאמה אישית של המעבר

אפשר לטרגט את כל פסאודו-הרכיבים של המעבר בין תצוגות באמצעות CSS, ומכיוון שהאנימציות מוגדרות באמצעות CSS, אפשר לשנות אותן באמצעות מאפייני אנימציה קיימים ב-CSS. לדוגמה:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 5s;
}

בעקבות השינוי הזה, עכשיו יש עמעום איטי מאוד:

עמעום ארוך. הדגמה מינימלית. מקור.

אוקיי, זה עדיין לא מרשים. במקום זאת, הקוד הבא מיישם את מעבר הציר המשותף של Material Design:

@keyframes fade-in {
  from { opacity: 0; }
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}

@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

והנה התוצאה:

מעבר של ציר משותף. הדגמה מינימלית. מקור.

העברה של כמה רכיבים

בדמו הקודם, כל הדף מעורב במעבר של הציר המשותף. זה עובד ברוב הדף, אבל לא נראה שזה מתאים לכותרת, כי היא מחליקה החוצה רק כדי לחזור פנימה שוב.

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

.main-header {
  view-transition-name: main-header;
}

הערך של view-transition-name יכול להיות כל מה שתרצו (חוץ מ-none, שמשמעותו שאין שם למעבר). הוא משמש לזיהוי ייחודי של האלמנט במהלך המעבר.

והתוצאה של זה:

מעבר בציר משותף עם כותרת קבועה. הדגמה מינימלית. מקור.

עכשיו הכותרת נשארת במקום ומתעמעמת.

הצהרת ה-CSS הזו גרמה לשינוי בעץ הרכיב המדומה:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(main-header)
   └─ ::view-transition-image-pair(main-header)
      ├─ ::view-transition-old(main-header)
      └─ ::view-transition-new(main-header)

עכשיו יש שתי קבוצות של מעבר. אחת לכותרת ועוד אחת לכל השאר. אפשר לטרגט אליהם באופן עצמאי באמצעות שירות CSS, ולקבל מעברים שונים. למרות זאת, במקרה הזה main-header נשאר עם מעבר ברירת המחדל, שהוא עמעום הדרגתי.

בסדר, מעבר ברירת המחדל הוא לא רק מעבר הדרגתי, ה-::view-transition-group גם עובר:

  • מיקום וטרנספורמציה (באמצעות transform)
  • רוחב
  • גובה

זה לא היה חשוב עד עכשיו, כי הכותרת באותו גודל ובאותו מיקום משני צידי השינוי ב-DOM. אבל אפשר גם לחלץ את הטקסט בכותרת:

.main-header-text {
  view-transition-name: main-header-text;
  width: fit-content;
}

נעשה שימוש ב-fit-content כך שהרכיב יהיה גודל הטקסט במקום להימתח לרוחב הנותר. בלי זה, חץ החזרה מקטין את גודל רכיב טקסט הכותרת, במקום את אותו גודל בשני הדפים.

עכשיו יש לנו שלושה חלקים לשחק איתם:

::view-transition
├─ ::view-transition-group(root)
│  └─ …
├─ ::view-transition-group(main-header)
│  └─ …
└─ ::view-transition-group(main-header-text)
   └─ …

אבל שוב, נעבור עם ברירות המחדל:

טקסט כותרת נע. הדגמה מינימלית. מקור.

עכשיו טקסט הכותרת מחליק קצת כדי לפנות מקום ללחצן החזרה.


אנימציה של מספר פסאודו אלמנטים באותו אופן עם view-transition-class

תמיכה בדפדפן

  • Chrome:‏ 125.
  • Edge:‏ 125.
  • Firefox: לא נתמך.
  • Safari: לא נתמך.

נניח שיש לכם מעבר תצוגה עם כמה כרטיסים, אבל גם כותרת בדף. כדי להוסיף אנימציה לכל הכרטיסים מלבד שם הפריט, צריך לכתוב בורר שמטרגט כל כרטיס בנפרד.

h1 {
    view-transition-name: title;
}
::view-transition-group(title) {
    animation-timing-function: ease-in-out;
}

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }

#card20 { view-transition-name: card20; }

::view-transition-group(card1),
::view-transition-group(card2),
::view-transition-group(card3),
::view-transition-group(card4),

::view-transition-group(card20) {
    animation-timing-function: var(--bounce);
}

יש לכם 20 רכיבים? אלה 20 סלקטורים שאתם צריכים לכתוב. הוספת רכיב חדש? לאחר מכן צריך להרחיב גם את הבורר שחלה עליו סגנונות האנימציה. לא ניתן להתאים אותם בקלות לעומס.

אפשר להשתמש ב-view-transition-class בפסאודו-רכיבים של מעבר התצוגה כדי להחיל את אותו כלל סגנון.

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
#card5 { view-transition-name: card5; }

#card20 { view-transition-name: card20; }

#cards-wrapper > div {
  view-transition-class: card;
}
html::view-transition-group(.card) {
  animation-timing-function: var(--bounce);
}

בדוגמה הבאה לכרטיסים נעשה שימוש בקטע הקוד הקודם של ה-CSS. המערכת תחיל את אותו תזמון בכל הכרטיסים – כולל כרטיסים חדשים שהוספתם – באמצעות בורר אחד: html::view-transition-group(.card).

הקלטה של ההדגמה של הכרטיסים. כשמשתמשים בפונקציה view-transition-class, המערכת מחילה את אותו animation-timing-function על כל הכרטיסים, חוץ מאלה שנוספו או הוסרו.

ניפוי באגים במעברים

מעברי תצוגה מבוססים על אנימציות CSS, לכן החלונית אנימציות בכלי הפיתוח ב-Chrome היא כלי נהדר לניפוי באגים במעברים.

באמצעות הלוח Animations (אנימציות), אפשר להשהות את האנימציה הבאה ואז לעבור קדימה ואחורה באנימציה. במהלך התהליך, פסאודו-האלמנטים של המעבר יופיעו בחלונית Elements.

ניפוי באגים של מעברים בין תצוגות באמצעות כלי הפיתוח ל-Chrome.

רכיבי המעבר לא חייבים להיות אותו רכיב DOM

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

לדוגמה, אפשר לתת view-transition-name להטמעת הסרטון הראשי:

.full-embed {
  view-transition-name: full-embed;
}

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

thumbnail.onclick = async () => {
  thumbnail.style.viewTransitionName = 'full-embed';

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = '';
    updateTheDOMSomehow();
  });
};

והתוצאה:

מעבר לרכיב אחר. הדגמה מינימלית. מקור.

עכשיו התמונה הממוזערת תעבור לתמונה הראשית. למרות שהם רכיבים שונים מבחינה מושגית (וגם מבחינה מילולית), ה-API למעבר מתייחס אליהם כאל אותו הדבר כי הם חולקים את אותו view-transition-name.

הקוד האמיתי של המעבר הזה קצת יותר מורכב מהדוגמה הקודמת, כי הוא מטפל גם במעבר חזרה לדף התמונות הממוזערות. במקור מפורטת ההטמעה המלאה.


מעברים בהתאמה אישית לכניסה וליציאה

נבחן את הדוגמה הזו:

כניסה לסרגל הצד ויציאה ממנו. הדגמה מינימלית. מקור.

סרגל הצד הוא חלק מהמעבר:

.sidebar {
  view-transition-name: sidebar;
}

עם זאת, בניגוד לכותרת בדוגמה הקודמת, סרגל הצד לא מופיע בכל הדפים. אם בשתי המדינות יש סרגל צד, פסאודו-רכיבי המעבר נראים כך:

::view-transition
├─ …other transition groups…
└─ ::view-transition-group(sidebar)
   └─ ::view-transition-image-pair(sidebar)
      ├─ ::view-transition-old(sidebar)
      └─ ::view-transition-new(sidebar)

עם זאת, אם סרגל הצד נמצא רק בדף החדש, הרכיב ::view-transition-old(sidebar) לא יופיע. מכיוון שאין תמונה 'ישנה' לסרגל הצד, בזוג התמונות יופיע רק ::view-transition-new(sidebar). באופן דומה, אם סרגל הצד מופיע רק בדף הישן, בזוג התמונות יופיע רק ::view-transition-old(sidebar).

בדמו הקודמת, חלונית הצד עוברת מעברים שונים בהתאם למצב שלה – כניסה, יציאה או נוכחות בשני המצבים. הכניסה אליו מתבצעת על ידי החלקה מימין והדרגתיות פנימה, היא יוצאת ממנו על ידי החלקה ימינה ו עמעום, ונשארת במקומה כאשר היא מופיעה בשני המצבים.

כדי ליצור מעברים ספציפיים לכניסה וליציאה, אפשר להשתמש בפסאודו-מחלקה :only-child כדי לטרגט את הרכיב הישן או החדש לרכיבים החדשים, אם הוא הילד היחיד בצמד התמונות:

/* Entry transition */
::view-transition-new(sidebar):only-child {
  animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Exit transition */
::view-transition-old(sidebar):only-child {
  animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

במקרה כזה, אין מעבר ספציפי כשסרגל הצד נמצא בשני המצבים, כי ברירת המחדל מושלמת.

עדכוני DOM אסינכרוניים והמתנה לתוכן

פונקציית ה-callback שמעבירים ל-.startViewTransition() יכולה להחזיר הבטחה (promise), שמאפשרת עדכוני DOM אסינכרונים ולהמתין עד שהתוכן החשוב יהיה מוכן.

document.startViewTransition(async () => {
  await something;
  await updateTheDOMSomehow();
  await somethingElse;
});

המעבר לא יתחיל עד שההתחייבות תתמלא. במהלך התקופה הזו הדף הוקפא, כך שצריך לצמצם ככל האפשר את העיכובים. באופן ספציפי, צריך לבצע אחזור נתונים מהרשת לפני שמפעילים את .startViewTransition(), בזמן שהדף עדיין מאפשר פעילות מלאה, ולא לבצע אותם כחלק מהקריאה החוזרת (callback) של .startViewTransition().

אם תחליטו להמתין עד שהתמונות או הגופנים יהיו מוכנים, הקפידו להגדיר זמן קצוב קצר:

const wait = ms => new Promise(r => setTimeout(r, ms));

document.startViewTransition(async () => {
  updateTheDOMSomehow();

  // Pause for up to 100ms for fonts to be ready:
  await Promise.race([document.fonts.ready, wait(100)]);
});

עם זאת, במקרים מסוימים עדיף להימנע לגמרי מהעיכוב, ולהשתמש בתוכן שכבר נמצא ברשותכם.


מפיקים את המירב מהתוכן שכבר יש לכם

במקרה שבו התמונה הממוזערת עוברת לתמונה גדולה יותר:

התמונה הממוזערת עוברת לתמונה גדולה יותר. אפשר לנסות את אתר ההדגמה.

ברירת המחדל של המעבר היא מעבר הדרגתי, כלומר התמונה הממוזערת יכולה לעבור מעבר הדרגתי לתמונה המלאה שעדיין לא נטענה.

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

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
}

עכשיו התמונה הממוזערת לא נעלמת, היא פשוט מוצבת מתחת לתמונה המלאה. המשמעות היא שאם התצוגה החדשה לא נטענה, התמונה הממוזערת תהיה גלויה לכל אורך המעבר. המשמעות היא שהמעבר יכול להתחיל באופן מיידי והתמונה המלאה יכולה להיטען בזמן משלה.

הפעולה הזו לא תפעל אם התצוגה החדשה תכלול שקיפות, אבל במקרה הזה אנחנו יודעים שהיא לא כוללת שקיפות, ולכן אנחנו יכולים לבצע את האופטימיזציה הזו.

טיפול בשינויים ביחס גובה-רוחב

לנוחותך, כל המעברים עד כה היו לאלמנטים עם אותו יחס גובה-רוחב, אבל לא תמיד זה יהיה המצב. מה קורה אם התמונה הממוזערת היא ביחס גובה-רוחב של 1:1 והתמונה הראשית היא ביחס גובה-רוחב של 16:9?

רכיב אחד עובר לרכיב אחר, עם שינוי ביחס גובה-רוחב. הדגמה מינימלית. מקור.

במעבר ברירת המחדל, הקבוצה מכילה אנימציה מהגודל שלפני הגודל ומהגודל שאחרי הגודל שלו. התצוגות הישנות והחדשות תופסות 100% מהרוחב של הקבוצה, והגובה שלהן נקבע באופן אוטומטי. כלומר, יחס הגובה-רוחב שלהן נשמר ללא קשר לגודל הקבוצה.

זוהי ברירת מחדל טובה, אך זו לא מה שרצית במקרה זה. כך:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* The old view is the thumbnail */
::view-transition-old(full-embed) {
  /* Maintain the aspect ratio of the view,
  by shrinking it to fit within the bounds of the element */
  object-fit: contain;
}

/* The new view is the full image */
::view-transition-new(full-embed) {
  /* Maintain the aspect ratio of the view,
  by growing it to cover the bounds of the element */
  object-fit: cover;
}

פירוש הדבר הוא שהתמונה הממוזערת נשארת במרכז האלמנט ככל שהרוחב מתרחב, אבל התמונה המלאה לא חתוכה בזמן המעבר מ-1:1 ל-16:9.

מידע מפורט יותר זמין במאמר מעבר בין תצוגות: טיפול בשינויים ביחס גובה-רוחב.


שימוש בשאילתות מדיה כדי לשנות מעברים במצבים שונים של מכשירים

ייתכן שתרצו להשתמש במעברים שונים בנייד לעומת במחשב שולחני, כמו בדוגמה הבאה, שבעזרתה תוכלו להציג שקף מלא מהצד בנייד, אבל להעביר שקף עדין יותר במחשב:

מעבר לרכיב אחר. הדגמה מינימלית. מקור.

אפשר לעשות זאת באמצעות שאילתות מדיה רגילות:

/* Transitions for mobile */
::view-transition-old(root) {
  animation: 300ms ease-out both full-slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out both full-slide-from-right;
}

@media (min-width: 500px) {
  /* Overrides for larger displays.
  This is the shared axis transition from earlier in the article. */
  ::view-transition-old(root) {
    animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
  }

  ::view-transition-new(root) {
    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
  }
}

כדאי גם לשנות את הרכיבים שאתם מקצים להם את הערך view-transition-name בהתאם לשאילתות מדיה תואמות.


תגובה ל'תנועה מופחתת' העדפה

המשתמשים יכולים לציין שהם מעדיפים תנועה מופחתת דרך מערכת ההפעלה שלהם, ושההעדפה הזו חשופה ב-CSS.

אתם יכולים למנוע את כל המעברים למשתמשים הבאים:

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

לעומת זאת, העדפה ל'תנועה מופחתת' לא אומר שהמשתמש רוצה ללא תנועה. במקום קטע הקוד הקודם, אפשר לבחור אנימציה עדינה יותר, שעדיין מבטאת את הקשר בין הרכיבים לזרימת הנתונים.


טיפול בכמה סגנונות של מעבר בין תצוגות באמצעות סוגי מעברים של תצוגות מפורטות

תמיכה בדפדפנים

  • Chrome:‏ 125.
  • קצה: 125.
  • Firefox: לא נתמך.
  • Safari: לא נתמך.

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

הקלטה של הדגמת העימוד. המערכת משתמשת במעברים שונים בהתאם לדף שאליו עוברים.

לשם כך, אפשר להשתמש בסוגי מעברים בין תצוגות מפורטות, שמאפשרים להקצות סוג אחד או יותר למעבר בין תצוגה פעילה. לדוגמה, כשעוברים לדף גבוה יותר בסדרת דפים, משתמשים בסוג forwards, וכשהולכים לדף נמוך יותר, משתמשים בסוג backwards. הסוגים האלה פעילים רק כשמצלמים או מבצעים מעבר, וניתן להתאים אישית כל סוג באמצעות CSS כדי להשתמש באנימציות שונות.

כדי להשתמש בסוגי תצוגה במעבר בין תצוגות של אותו מסמך, מעבירים את types לשיטה startViewTransition. כדי לאפשר זאת, document.startViewTransition מקבל גם אובייקט: update היא פונקציית הקריאה החוזרת שמעדכנת את ה-DOM ו-types היא מערך עם הסוגים.

const direction = determineBackwardsOrForwards();

const t = document.startViewTransition({
  update: updateTheDOMSomehow,
  types: ['slide', direction],
});
.

כדי להגיב לסוגים האלה, צריך להשתמש בבורר :active-view-transition-type(). מעבירים את type שרוצים לטרגט אל הבורר. כך אפשר להפריד בין הסגנונות של כמה מעברים בין תצוגות, בלי שההצהרות של אחד יפריעו להצהרות של השני.

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

/* Determine what gets captured when the type is forwards or backwards */
html:active-view-transition-type(forwards, backwards) {
  :root {
    view-transition-name: none;
  }
  article {
    view-transition-name: content;
  }
  .pagination {
    view-transition-name: pagination;
  }
}

/* Animation styles for forwards type only */
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-right;
  }
}

/* Animation styles for backwards type only */
html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-left;
  }
}

/* Animation styles for reload type only (using the default root snapshot) */
html:active-view-transition-type(reload) {
  &::view-transition-old(root) {
    animation-name: fade-out, scale-down;
  }
  &::view-transition-new(root) {
    animation-delay: 0.25s;
    animation-name: fade-in, scale-up;
  }
}

בהדגמה של החלוקה לדפים, תוכן הדף מחליקים קדימה או אחורה בהתאם למספר הדף שאליו אתם מנווטים. הסוגים נקבעים לפי הקליק שאליו הם מועברים אל document.startViewTransition.

כדי לטרגט כל מעבר פעיל בין תצוגות, ללא קשר לסוג, אפשר להשתמש במקום זאת בבורר של פסאודו-הקלאסה :active-view-transition.

html:active-view-transition {
    
}

טיפול במספר סגנונות של מעברים בין תצוגות באמצעות שם של סיווג ברמה הבסיסית של מעברי התצוגות

לפעמים מעבר מסוג מסוים לתצוגה אחרת צריך לכלול מעבר מותאם אישית. לחלופין, ניווט 'לאחור' צריך להיות שונה מניווט 'קדימה'.

מעברים שונים כשחוזרים אחורה. הדגמה מינימלית. מקור.

לפני סוגי המעבר, הדרך לטפל במקרים האלה הייתה להגדיר שם מחלקה באופן זמני ברמה הבסיסית של המעבר. כשמבצעים קריאה ל-document.startViewTransition, שורש המעבר הזה הוא הרכיב <html>, שאליו ניתן לגשת באמצעות document.documentElement ב-JavaScript:

if (isBackNavigation) {
  document.documentElement.classList.add('back-transition');
}

const transition = document.startViewTransition(() =>
  updateTheDOMSomehow(data)
);

try {
  await transition.finished;
} finally {
  document.documentElement.classList.remove('back-transition');
}

כדי להסיר את הכיתות אחרי שהמעבר מסתיים, בדוגמה הזו נעשה שימוש ב-transition.finished, הבטחה שמתמלאת ברגע שהמעבר מגיע למצב הסיום שלו. מאפיינים אחרים של האובייקט הזה מפורטים בהפניה ל-API.

עכשיו אפשר להשתמש בשם המחלקה הזה ב-CSS כדי לשנות את המעבר:

/* 'Forward' transitions */
::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms
      cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Overrides for 'back' transitions */
.back-transition::view-transition-old(root) {
  animation-name: fade-out, slide-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: fade-in, slide-from-left;
}

בדומה לשאילתות מדיה, אפשר להשתמש בנוכחות של המחלקות האלה גם כדי לשנות את הרכיבים שמקבלים view-transition-name.


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

כדאי לצפות בהדגמה הזו של מיקום מעבר של סרטון:

מעבר בין סרטונים. הדגמה מינימלית. מקור.

האם ראית משהו לא בסדר? אם לא, אל דאגה. כאן אפשר לראות את התהליך בהילוך איטי:

מעבר בין סרטונים, איטי יותר. הדגמה מינימלית. מקור.

במהלך המעבר, נראה שהסרטון קופא, ולאחר מכן גרסת ההפעלה של הסרטון נעלמת. הסיבה לכך היא ש-::view-transition-old(video) הוא צילום מסך של התצוגה הישנה, ואילו ::view-transition-new(video) הוא תמונה פעילה של התצוגה החדשה.

אפשר לתקן את הבעיה הזו, אבל קודם צריך לשאול את עצמכם אם כדאי לתקן אותה. אם לא ראיתם את 'הבעיה' כשהמעבר פעלו במהירות הרגילה שלו, לא הייתי מטרד לשנות אותו.

אם באמת רוצים לתקן את זה, לא מציגים את ::view-transition-old(video); עוברים ישירות ל-::view-transition-new(video). כדי לעשות את זה אפשר לעקוף את הסגנונות והאנימציות שמוגדרים כברירת מחדל:

::view-transition-old(video) {
  /* Don't show the frozen old view */
  display: none;
}

::view-transition-new(video) {
  /* Don't fade the new view in */
  animation: none;
}

זה הכול!

מעבר בין סרטונים, איטי יותר. הדגמה מינימלית. מקור.

עכשיו הסרטון יופעל במהלך המעבר.


אנימציה עם JavaScript

עד עכשיו, כל המעברים הוגדרו באמצעות CSS, אבל לפעמים CSS לא מספיק:

מעבר מעגלי. הדגמה מינימלית. מקור.

יש כמה חלקים מהמעבר הזה שאי אפשר להשיג באמצעות שירות CSS בלבד:

  • האנימציה מתחילה ממיקום הקליק.
  • אנימציית המעגל מסתיימת כשהיא מגיעת לפינה הרחוקה ביותר. אבל אני מקווה שהדבר יהיה אפשרי באמצעות שירות CSS בעתיד.

למרבה המזל, אפשר ליצור מעברים באמצעות Web Animation API.

let lastClick;
addEventListener('click', event => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );

  // With a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in',
        // Specify which pseudo-element to animate
        pseudoElement: '::view-transition-new(root)',
      }
    );
  });
}

בדוגמה הזו נעשה שימוש ב-transition.ready, הבטחה שמתבטלת לאחר שרכיבי המעבר לכאורה נוצרים בהצלחה. מאפיינים אחרים של האובייקט הזה מפורטים בחומר העזר בנושא API.


מעברים כתכונה משופרת

ממשק ה-API למעבר של View נועד "לכווץ" שינוי DOM וליצור עבורו מעבר. עם זאת, צריך להתייחס למעבר כאל שיפור. כלומר, האפליקציה לא אמורה להיכנס למצב 'שגיאה' אם שינוי ה-DOM מצליח אבל המעבר נכשל. באופן אידיאלי, המעבר לא אמור להיכשל, אבל אם הוא נכשל, הוא לא אמור לשבש את שאר חוויית המשתמש.

כדי להתייחס למעברים כשיפור, הקפידו לא להשתמש בהבטחות מעבר באופן שיגרום לאפליקציה לפעול אם המעבר ייכשל.

מה אסור לעשות
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  await transition.ready;

  document.documentElement.animate(
    {
      clipPath: [`inset(50%)`, `inset(0)`],
    },
    {
      duration: 500,
      easing: 'ease-in',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

הבעיה בדוגמה הזו היא ש-switchView() ידחה אם המעבר לא יכול להגיע למצב ready, אבל זה לא אומר שהתצוגה לא הצליחה לעבור. יכול להיות שה-DOM עודכן בהצלחה, אבל היו view-transition-name כפולים, ולכן ההעברה דילגה.

במקום זאת:

מה מותר לעשות
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  animateFromMiddle(transition);

  await transition.updateCallbackDone;
}

async function animateFromMiddle(transition) {
  try {
    await transition.ready;

    document.documentElement.animate(
      {
        clipPath: [`inset(50%)`, `inset(0)`],
      },
      {
        duration: 500,
        easing: 'ease-in',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  } catch (err) {
    // You might want to log this error, but it shouldn't break the app
  }
}

בדוגמה הזו נעשה שימוש ב-transition.updateCallbackDone כדי להמתין לעדכון ה-DOM, ולדחות אם הוא נכשל. switchView לא דוחה יותר אם המעבר נכשל, הוא פותר כשעדכון ה-DOM מסתיים, ודחה אם הוא נכשל.

אם רוצים שהפעולה switchView תתבצע כשהתצוגה החדשה 'תתייצב', כלומר כשכל המעבר האנימציה יושלם או ידלג לסוף, צריך להחליף את transition.updateCallbackDone ב-transition.finished.


לא מילוי פוליגונים, אבל...

זו לא תכונה קלה ל-polyfill. עם זאת, פונקציית העזרה הזו מאפשרת לבצע את הפעולות האלה בקלות רבה יותר בדפדפנים שלא תומכים במעברים בין תצוגות:

function transitionHelper({
  skipTransition = false,
  types = [],
  update,
}) {

  const unsupported = (error) => {
    const updateCallbackDone = Promise.resolve(update()).then(() => {});

    return {
      ready: Promise.reject(Error(error)),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
      types,
    };
  }

  if (skipTransition || !document.startViewTransition) {
    return unsupported('View Transitions are not supported in this browser');
  }

  try {
    const transition = document.startViewTransition({
      update,
      types,
    });

    return transition;
  } catch (e) {
    return unsupported('View Transitions with types are not supported in this browser');
  }
}

ניתן להשתמש בו כך:

function spaNavigate(data) {
  const types = isBackNavigation ? ['back-transition'] : [];

  const transition = transitionHelper({
    update() {
      updateTheDOMSomehow(data);
    },
    types,
  });

  // …
}

בדפדפנים שלא תומכים במעברים בין תצוגות, עדיין תתבצע קריאה ל-updateDOM, אבל לא תהיה אנימציה של המעבר.

אפשר גם להוסיף כמה classNames ל<html> במהלך המעבר, וכך קל יותר לשנות את המעבר בהתאם לסוג הניווט.

אפשר גם להעביר את true אל skipTransition אם לא רוצים ליצור אנימציה, גם בדפדפנים שתומכים במעברים בין תצוגות. האפשרות הזו שימושית אם באתר יש העדפה של משתמשים להשבית את המעברים.


עבודה עם מסגרות

אם אתם עובדים עם ספרייה או מסגרת שמבודדות שינויים ב-DOM, החלק הקשה הוא לדעת מתי השינוי ב-DOM הושלם. ריכזנו כאן כמה דוגמאות לשימוש בפונקציית העזרה שלמעלה, במסגרות שונות.

  • React – המפתח כאן הוא flushSync, שמחיל קבוצה של שינויי מצב באופן סינכרוני. כן, יש אזהרה גדולה לגבי השימוש ב-API הזה, אבל דן אברמוב מבטיח לי שהוא מתאים במקרה הזה. כמו בכל קוד תגובה וקוד אסינכרוני, כשמשתמשים בהבטחות השונות שהוחזרו על ידי startViewTransition, צריך לוודא שהקוד פועל במצב הנכון.
  • Vue.js – המפתח כאן הוא nextTick, שממלא אחרי עדכון ה-DOM.
  • Svelte – דומה מאוד ל-Vue, אבל השיטה להמתין לשינוי הבא היא tick.
  • Lit – המפתח כאן הוא ההבטחה this.updateComplete בתוך הרכיבים, שמתממשת אחרי שה-DOM מתעדכן.
  • Angular – המפתח כאן הוא applicationRef.tick, שמאפס את השינויים בהמתנה ב-DOM. החל מגרסה 17 של Angular, אפשר להשתמש ב-withViewTransitions שמגיע עם @angular/router.

הפניית API

const viewTransition = document.startViewTransition(update)

מתחילים ViewTransition חדש.

update היא פונקציה שנקראת אחרי תיעוד המצב הנוכחי של המסמך.

לאחר מכן, כשההתחייבות שחוזרת מ-updateCallback מתמלאת, המעבר מתחיל בפריים הבא. אם ההבטחה שהוחזרה על ידי updateCallback תידחה, המעבר יופסק.

const viewTransition = document.startViewTransition({ update, types })

התחלת ViewTransition חדש עם הסוגים שצוינו

מתבצעת קריאה אל update אחרי תיעוד המצב הנוכחי של המסמך.

types מגדיר את הסוגים הפעילים למעבר בזמן תיעוד או ביצוע של המעבר. בהתחלה היא ריקה. מידע נוסף זמין ב-viewTransition.types למטה.

חברים במכונה של ViewTransition:

viewTransition.updateCallbackDone

הבטחה שמתמלאת כשהבטחה שחוזרת מ-updateCallback מתמלאת, או שנדחית כשהיא נדחית.

View Transit API כולל שינוי DOM ויוצר מעבר. עם זאת, לפעמים לא מעניין אתכם אם אנימציית המעבר הושלמה או לא, אלא רק אם השינוי ב-DOM מתרחש ומתי הוא מתרחש. updateCallbackDone הוא לתרחיש לדוגמה הזה.

viewTransition.ready

הבטחה שמתממשת לאחר יצירת פסאודו הרכיבים של המעבר, והאנימציה עומדת להתחיל.

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

אפשר להשתמש בזה כדי ליצור אנימציה לפסאודו-רכיבים של המעבר באמצעות JavaScript.

viewTransition.finished

הבטחה שמתממשת ברגע שמצב הקצה גלוי ואינטראקטיבי למשתמש.

הוא דוחה רק אם updateCallback מחזיר הבטחה שנדחתה, כי זה מציין שהמצב הסופי לא נוצר.

אחרת, אם מעבר לא מתחיל, או אם מדלגים עליו במהלך המעבר, מצב הסיום עדיין יגיע, כך ש-finished מתקיים.

viewTransition.types

אובייקט כמו Set שמכיל את סוגי המעבר של התצוגה הפעילה. כדי לבצע פעולות על הרשומות, משתמשים בשיטות המכונה clear(),‏ add() ו-delete().

כדי להגיב לסוג ספציפי ב-CSS, משתמשים בבורר הסיווג המזויף :active-view-transition-type(type) ברמה הבסיסית של המעבר.

סוגי הפריטים ינוקו באופן אוטומטי בסיום המעבר בין התצוגה.

viewTransition.skipTransition()

דילוג על החלק של האנימציה במעבר.

הפעולה הזו לא תדלג על הקריאה ל-updateCallback, כי השינוי ב-DOM נפרד מהמעבר.


מסמך עזר בנושא סגנון ברירת מחדל ומעברים

::view-transition
רכיב השורש שממלא את אזור התצוגה ומכיל כל ::view-transition-group.
::view-transition-group

מיקום מוחלט.

מעברים width ו-height בין המצבים 'לפני' ו'אחרי'.

מעברים transform בין הרבעון 'לפני' לבין הרבעון 'אחרי' במרחב התצוגה.

::view-transition-image-pair

נמצא בעמדה מלאה למילוי הקבוצה.

יש לו isolation: isolate כדי להגביל את ההשפעה של mix-blend-mode על התצוגות הישנות והחדשות.

::view-transition-new וגם ::view-transition-old

מיקום מוחלט בפינה הימנית העליונה של העטיפה.

התמונה ממלאת 100% מהרוחב של הקבוצה, אבל הגובה שלה הוא אוטומטי, כך שהיא תישאר ביחס גובה-רוחב קבוע ולא תמלא את הקבוצה.

יש לו mix-blend-mode: plus-lighter כדי לאפשר מעבר חלק בין שני הצלילים.

התצוגה הישנה עוברת מ-opacity: 1 ל-opacity: 0. התצוגה החדשה עוברת מ-opacity: 0 ל-opacity: 1.


משוב

המשוב מהמפתחים תמיד חשוב לנו. כדי לעשות זאת, צריך לדווח על בעיה בקבוצת העבודה של CSS ב-GitHub עם הצעות ושאלות. מוסיפים את הקידומת [css-view-transitions] לבעיה.

אם נתקלתם בבאג, תוכלו לדווח על באג ב-Chromium במקום זאת.