สร้างภาพเคลื่อนไหวสำหรับการเข้าและออกขององค์ประกอบ

ฟีเจอร์บนเว็บ: @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 เพื่อให้องค์ประกอบเหล่านั้นยังคงอยู่ในชั้นบนสุดระหว่างภาพเคลื่อนไหวเมื่อออก
  • ควรทำ: เคารพค่ากำหนดของผู้ใช้สำหรับการลดการเคลื่อนไหวโดยใช้ Media Query prefers-reduced-motion
  • อย่า: ใช้ @starting-style สำหรับภาพเคลื่อนไหวเมื่อออก เนื่องจากจะกำหนดเฉพาะจุดเริ่มต้นสำหรับการเปลี่ยนภาพเมื่อเข้าเท่านั้น ภาพเคลื่อนไหวเมื่อออกจะกำหนดโดยการเปลี่ยนสถานะเป็นซ่อน

กลยุทธ์สำรอง

การสนับสนุนเบราว์เซอร์และทางเลือกสำรองสำหรับ transition-behavior

Firefox 129 ขึ้นไปจะแยกวิเคราะห์ 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();
}