jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CSS Entry & Exit Animations with @starting-style

Animate elements as they enter and leave the DOM using @starting-style, transition-behavior allow-discrete, popovers and dialogs — no JavaScript animation library needed.

Trong nhiều năm, việc làm cho một phần tử xuất hiện kèm animation là một điệu nhảy vô vọng. Phần tử không có trạng thái “trước” để transition nội suy từ đó. Bạn phải dùng JavaScript, double requestAnimationFrame, hay toggle class ở tick kế tiếp.

CSS hiện đại giải quyết điều này bằng @starting-style, transition-behavior: allow-discrete, và tích hợp chặt với <dialog> cùng Popover API. Bài này là một tour cấp senior, copy-paste được ngay.

Mở demo đầy đủ:

The core problem: there is no “before” state

Một transition chuyển động giữa hai giá trị đã biết. Khi một phần tử vừa được chèn vào DOM — hoặc hiện ra từ display: none — trình duyệt không có gì để bắt đầu. Nó chỉ vẽ trạng thái cuối ngay lập tức, không animation.

/* This does NOT animate on insertion. */
.toast {
  opacity: 1;
  transition: opacity 0.3s ease;
}
/* Element appears at opacity:1 instantly — there is no prior 0 to animate from. */

@starting-style giải quyết đúng điều này: nó khai báo giá trị dùng cho lần cập nhật style đầu tiên của phần tử (hoặc khi nó được render lại).

Meet @starting-style

Hãy coi @starting-style như keyframe “from” cho transition lúc vào. Trình duyệt đọc nó một lần, vẽ trạng thái khởi đầu đó, rồi transition tới rule thật.

.toast {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.3s ease, translate 0.3s ease;
}

/* The "from" state used only for the first render. */
@starting-style {
  .toast {
    opacity: 0;
    translate: 0 1rem;
  }
}

Giờ khi .toast vào DOM, nó bắt đầu ở opacity: 0; translate: 0 1rem, rồi animate về trạng thái bình thường. Không JavaScript, không hack rAF.

Nested vs standalone @starting-style

Có hai cú pháp, và khác biệt là về specificity và độ gần, không phải hành vi.

/* 1) Standalone — wraps a full selector. */
@starting-style {
  .modal { opacity: 0; }
}

/* 2) Nested — sits inside an existing rule. */
.modal {
  opacity: 1;
  transition: opacity 0.3s;

  @starting-style {
    opacity: 0;
  }
}

Cả hai đều chạy. Dạng nested thường gọn hơn vì trạng thái đầu nằm ngay cạnh trạng thái cuối. Một điểm tinh tế: dạng standalone @starting-style { .modal {} }specificity bằng .modal, nên thứ tự nguồn có thể quan trọng khi rule hòa. Hãy đặt @starting-style sau rule gốc mà nó đi kèm.

The exit problem: animating back to display: none

Vào chỉ là một nửa câu chuyện. Ra khó hơn vì ẩn thường nghĩa là display: none, mà display là thuộc tính discrete — nó nhảy tức thì, giết luôn hiệu ứng fade-out.

transition-behavior: allow-discrete bảo trình duyệt hoãn thay đổi discrete cho đến khi transition xong. Vậy display giữ block trong khi opacity về 0, rồi mới nhảy sang none ở cuối.

.dropdown {
  opacity: 1;
  display: block;

  transition: opacity 0.25s ease, display 0.25s allow-discrete;
}

.dropdown[hidden] {
  opacity: 0;
  display: none;
}

@starting-style {
  .dropdown:not([hidden]) {
    opacity: 0;
  }
}

Lưu ý display được liệt kê trong transition kèm allow-discrete. Không có nó, phần tử biến mất trước khi fade kịp chạy. Bạn cũng có thể viết shorthand một lần: transition-behavior: allow-discrete; áp cho mọi thuộc tính discrete trong cùng transition.

The three pieces, together

Một animation vào + ra đầy đủ cần cả ba phối hợp như một đội.

  1. @starting-style → trạng thái “from” cho lúc vào.
  2. transition → nội suy các thuộc tính nhìn thấy được.
  3. transition-behavior: allow-discrete → giữ phần tử được render trong lúc ra.

Recipe: <dialog> modal

<dialog> native chuyển giữa display: nonedisplay: block, cùng backdrop của nó. Đây là use case kinh điển của @starting-style.

<dialog id="confirm">
  <h2>Delete this post?</h2>
  <p>This action cannot be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="ok">Delete</button>
  </form>
</dialog>

<button id="open">Open dialog</button>
dialog {
  opacity: 0;
  translate: 0 -1rem;
  transition:
    opacity 0.3s ease,
    translate 0.3s ease,
    overlay 0.3s allow-discrete,
    display 0.3s allow-discrete;
}

/* Visible state — [open] is set by the browser. */
dialog[open] {
  opacity: 1;
  translate: 0 0;
}

/* Entry "from" state. */
@starting-style {
  dialog[open] {
    opacity: 0;
    translate: 0 -1rem;
  }
}

/* Animate the backdrop too. */
dialog::backdrop {
  background: rgb(0 0 0 / 0);
  transition:
    background 0.3s ease,
    overlay 0.3s allow-discrete,
    display 0.3s allow-discrete;
}

dialog[open]::backdrop {
  background: rgb(0 0 0 / 0.5);
}

@starting-style {
  dialog[open]::backdrop {
    background: rgb(0 0 0 / 0);
  }
}
const dialog = document.getElementById('confirm');
document.getElementById('open').addEventListener('click', () => {
  dialog.showModal();
});

Thuộc tính overlay trong transition giữ dialog ở top layer cho đến khi animation xong — rất quan trọng để thoát mượt.

Recipe: Popover API

Popover API cho bạn light-dismiss và hành vi top-layer miễn phí. Ghép nó với @starting-style để animation không cần JS.

<button popovertarget="menu">Toggle menu</button>

<div id="menu" popover>
  <a href="/blog/css-starting-style-entry-exit-animations">This post</a>
  <a href="/blog">All posts</a>
</div>
[popover] {
  opacity: 0;
  scale: 0.95;
  transition:
    opacity 0.2s ease,
    scale 0.2s ease,
    overlay 0.2s allow-discrete,
    display 0.2s allow-discrete;
}

/* :popover-open is the visible state. */
[popover]:popover-open {
  opacity: 1;
  scale: 1;
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    scale: 0.95;
  }
}

Nút dùng popovertarget — không cần event listener. Trình duyệt lo việc mở, đóng, Escape, và click ra ngoài.

Recipe: toast notification

Toast được chèn động, nên @starting-style tỏa sáng ở đây.

.toast {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 0.3s ease,
    translate 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}

@starting-style {
  .toast {
    opacity: 0;
    translate: 0 100%;
  }
}

/* Exit by adding .is-leaving before removal. */
.toast.is-leaving {
  opacity: 0;
  translate: 0 100%;
}
function showToast(message) {
  const el = document.createElement('div');
  el.className = 'toast';
  el.textContent = message;
  document.body.append(el); // @starting-style animates the entry

  setTimeout(() => {
    el.classList.add('is-leaving');
    el.addEventListener('transitionend', () => el.remove(), { once: true });
  }, 3000);
}

Với node chèn động bạn vẫn cần chút JS để xóa chúng sau khi transition ra xong. transitionend là hook gọn gàng nhất.

Recipe: dropdown & tooltip with [hidden]

Với UI không ở top-layer, toggle một attribute kiểu [hidden] và để allow-discrete lo display.

.tooltip {
  opacity: 1;
  scale: 1;
  display: block;
  transition:
    opacity 0.15s ease,
    scale 0.15s ease,
    display 0.15s allow-discrete;
}

.tooltip[data-closed] {
  opacity: 0;
  scale: 0.9;
  display: none;
}

@starting-style {
  .tooltip:not([data-closed]) {
    opacity: 0;
    scale: 0.9;
  }
}
const tip = document.querySelector('.tooltip');
trigger.addEventListener('mouseenter', () => tip.removeAttribute('data-closed'));
trigger.addEventListener('mouseleave', () => tip.setAttribute('data-closed', ''));

display nằm trong transition với allow-discrete, tooltip fade out trước khi bị ẩn, và @starting-style làm nó fade in.

Browser support & @supports fallbacks

@starting-styletransition-behavior: allow-discrete đã có trên Chrome/Edge (117+), Safari (17.4+), và Firefox (129+). Độ phủ tốt từ 2024 trở đi, nhưng progressive enhancement giữ an toàn cho trình duyệt cũ.

Tin tốt: kỹ thuật này thoái lui mượt mà. Trình duyệt không hiểu @starting-style chỉ bỏ qua animation vào và hiện trạng thái cuối — vẫn hoạt động đầy đủ, chỉ là không animate.

Nếu cần rẽ nhánh logic, dùng @supports để dò tính năng.

/* Apply enhanced styles only where allow-discrete is supported. */
@supports (transition-behavior: allow-discrete) {
  .dropdown {
    transition: opacity 0.25s ease, display 0.25s allow-discrete;
  }
}

/* Fallback: no display transition, instant toggle. */
@supports not (transition-behavior: allow-discrete) {
  .dropdown { transition: opacity 0.25s ease; }
}

Bản thân @starting-style khó dò trực tiếp, nhưng vì nó bị bỏ qua êm nên hiếm khi bạn cần.

Respect prefers-reduced-motion

Chuyển động là vấn đề accessibility. Luôn tôn trọng người dùng muốn ít chuyển động hơn.

@media (prefers-reduced-motion: reduce) {
  .toast,
  dialog,
  [popover],
  .tooltip,
  .dropdown {
    transition-duration: 0.01ms;
  }
}

Thời lượng gần như bằng 0 giữ việc bàn giao display discrete (nên thoát vẫn ẩn đúng) trong khi loại bỏ chuyển động cảm nhận được. Tránh transition: none ở đây, vì nó có thể phá timing của allow-discrete.

Mental model & gotchas

  • @starting-style chỉ cho lúc vào. Lúc ra do transition + allow-discrete xử lý.
  • Selector bên trong @starting-style phải khớp trạng thái hiện/mở (vd dialog[open], :popover-open).
  • Luôn liệt kê display (và overlay cho phần tử top-layer) trong transition kèm allow-discrete.
  • Với node chèn động, bạn vẫn cần JS để xóa chúng sau transitionend.
  • Giữ @starting-style nested ngay trong rule gốc để dễ đọc và thứ tự dễ đoán.

Takeaways

@starting-style cuối cùng đã cho CSS một trạng thái “from” hạng nhất cho phần tử đang vào. Kết hợp với transition-behavior: allow-discrete, bạn có animation vào ra đầy đủ cho dialog, popover, toast, dropdown, và tooltip — gần như không cần JavaScript. Nó thoái lui mượt và phối hợp tốt với prefers-reduced-motion. Hãy ưu tiên dùng nền tảng trước.