הנפשה של כניסה ויציאה של רכיבים

תכונות אינטרנט: ‎@starting-style transition-behavior

בעבר, מעברי CSS לא יכלו להוסיף אנימציה לאלמנטים כשהם נוספו לראשונה ל-DOM או כשהמאפיין display שלהם השתנה מ-none. הכלל @starting-style והמאפיין transition-behavior: allow-discrete מספקים דרך הצהרתית ליצור אנימציות חלקות של כניסה ויציאה.

הטמעה

1. הפעלה או השבתה של display: none מתגים

כדי להוסיף אנימציה לרכיב כשמשנים את החשיפה שלו באמצעות מאפיין (למשל, hidden עם display: none):

  1. הגדרת המצב הגלוי: מגדירים את ערכי המאפיינים הסופיים (למשל, opacity: 1) במחלקה הבסיסית.
  2. הגדרת מצב הכניסה: משתמשים ב-@starting-style כדי לציין את הערכים שמהם מתחיל המעבר כשהרכיב הופך לגלוי.
  3. הפעלת מעברים נפרדים: כוללים את display במאפיין transition ומשתמשים ב-transition-behavior: allow-discrete.
  4. הגדרת מצב היציאה: מגדירים את ערכי היעד במאפיין hidden.
.card {
  display: block;
  opacity: 1;
  translate: 0;
  /* MANDATORY: Use transition-behavior: allow-discrete for display transition */
  transition:
    display 0.4s,
    opacity 0.4s ease-out,
    translate 0.4s ease-out;
  transition-behavior: allow-discrete;
}

/* Entry animation: transition FROM these values when first rendered */
@starting-style {
  .card {
    opacity: 0;
    translate: 0 -20px;
  }
}

/* Exit animation: transition TO these values when hidden */
.card[hidden] {
  display: none;
  opacity: 0;
  translate: 0 -20px;
}

/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  .card {
    /* Disable movement and shorten duration for a simple fade */
    translate: none;
    transition-duration: 0.1s;
  }

  @starting-style {
    .card {
      translate: none;
    }
  }

  .card[hidden] {
    translate: none;
  }
}

2. הוספה והסרה של אנימציה ב-DOM

לרכיבים שנוספו באמצעות appendChild() או הוסרו באמצעות remove():

  • כניסה: משתמשים ב-@starting-style כמו שמוצג למעלה. הדפדפן יזהה באופן אוטומטי את השינוי בסגנון מ"כלום" לסגנונות הראשוניים של הרכיב, ויפעיל את המעבר מהערכים של @starting-style.
  • הסרה: מכיוון שהפעולה element.remove() היא מיידית ולא מפעילה מעבר CSS בפני עצמה, צריך להפעיל קודם את מעבר היציאה ולהמתין עד שהוא יסתיים לפני שמסירים את הצומת מה-DOM.
// 1. Trigger exit transition
element.hidden = true;

// 2. Wait for all active transitions/animations to finish,
//    with a failsafe timeout in case an animation never ends (e.g. for looping animations)
const animations = element.getAnimations();
if (animations.length > 0) {
  await Promise.race([
    // Promise.allSettled ensures we wait even if some animations fail
    Promise.allSettled(animations.map(a => a.finished)),
    new Promise(r => setTimeout(r, 2000))
  ]);
}

// 3. Finally remove the node from the DOM
element.remove();

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

  • חובה: צריך להשתמש ב-transition-behavior: allow-discrete כשעוברים ל-display. בלעדיו, הרכיב ייעלם מיד במהלך היציאה.
  • אל תשתמשו ב-allow-discrete בקיצור של transition – זה יגרום לדפדפנים ישנים להתעלם מכל ההצהרה של transition. למעט בתרחישים לדוגמה שבהם זה רצוי, כדאי להשתמש בהצהרת transition-behavior: allow-discrete נפרדת.
  • חובה: צריך להשתמש ב-@starting-style לאנימציות כניסה. דפדפנים מדלגים על מעברים בעדכון הסגנון הראשון של רכיב (רינדור ראשוני או שינוי של display: none), אלא אם המאפיין הזה צוין.
  • כדאי: לכלול את overlay ברשימה transition אם מוסיפים אנימציה לרכיבים בשכבה העליונה כמו <dialog> או popover כדי לוודא שהם יישארו בשכבה העליונה במהלך האנימציה ביציאה.
  • מה כן לעשות: כבדו את העדפות המשתמשים להפחתת תנועה באמצעות שאילתת המדיה prefers-reduced-motion.
  • מה לא לעשות: להסתמך על @starting-style לאנימציות יציאה. הוא מגדיר רק את נקודת ההתחלה של מעבר כניסה. אנימציות יציאה מוגדרות על ידי המעבר למצב מוסתר.

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

חלופות ותמיכה בדפדפנים ב-transition-behavior

גרסה 129 ומעלה של Firefox מנתחת את transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') מחזיר true) בלי להעביר בפועל את המאפיין display (באג 1882408 ב-Firefox), מה שגורם לרכיבים להיעלם מיד ביציאה.

כדי לזהות באופן מהימן תמיכה במעבר display בדיסקרטיות, צריך לבדוק אם הערך המחושב של display של רכיב זמני נשאר גלוי כשהוא עובר ל-none, במקום להסתמך רק על CSS.supports('transition-behavior', 'allow-discrete'):

let supportsDisplayTransition;
function canTransitionDisplay() {
  if (supportsDisplayTransition !== undefined) return supportsDisplayTransition;
  if (!window.CSS?.supports?.('transition-behavior', 'allow-discrete') || !document.body) {
    return false;
  }
  const probe = document.createElement('div');
  // The shorthand is intentional here: browsers that don't parse allow-discrete
  // drop the whole declaration, so display: none applies instantly and the probe
  // correctly returns false. !important guards against global reduced-motion
  // resets like `* { transition: none !important }`.
  probe.style.cssText = 'transition: display 1s allow-discrete !important; display: block;';
  document.body.appendChild(probe);
  getComputedStyle(probe).display;
  probe.style.display = 'none';
  supportsDisplayTransition = getComputedStyle(probe).display === 'block';
  probe.remove();
  return supportsDisplayTransition;
}

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

אנימציות כניסה שמשתמשות ב-@starting-style פועלות בכל הדפדפנים המודרניים בלי JavaScript. כשאין תמיכה במעברים נפרדים של display (!canTransitionDisplay()), הגדרת hidden מחילה את display: none באופן מיידי ומדלגת על אנימציית היציאה (גם בהסתרה וגם לפני element.remove()). צריך להפריד את מצב היציאה החזותי ([data-closing]) מ-display: none ([hidden]) כדי שהאנימציה של opacity ו-translate תסתיים לפני ההסתרה או ההסרה של הרכיב. מחליפים את הכללים של .card[hidden] משלב 1 (כולל הביטול של prefers-reduced-motion) בקוד הבא:

.card:where([hidden], [data-closing]) {
  opacity: 0;
  translate: 0 -20px;
}

.card[hidden] {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .card:where([hidden], [data-closing]) {
    translate: none;
  }
}
async function hideElement(el) {
  if (canTransitionDisplay()) {
    el.hidden = true;
  } else {
    el.setAttribute('data-closing', '');
  }

  const animations = el.getAnimations();
  if (animations.length > 0) {
    await Promise.race([
      Promise.allSettled(animations.map((a) => a.finished)),
      new Promise((r) => setTimeout(r, 2000)),
    ]);
  }

  // Skipped if showElement() cancelled the close mid-animation.
  if (el.hasAttribute('data-closing')) {
    el.removeAttribute('data-closing');
    el.hidden = true;
  }
}

function showElement(el) {
  el.removeAttribute('data-closing');
  el.hidden = false;
}

async function removeElement(el) {
  await hideElement(el);
  el.remove();
}