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.

For years, animating an element into existence was a hopeless dance. {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.} The element had no “before” state for transition to interpolate from. {Phần tử không có trạng thái “trước” để transition nội suy từ đó.} You reached for JavaScript, a double requestAnimationFrame, or a class toggle on the next tick. {Bạn phải dùng JavaScript, double requestAnimationFrame, hay toggle class ở tick kế tiếp.}

Modern CSS fixes this with @starting-style, transition-behavior: allow-discrete, and tight integration with <dialog> and the Popover API. {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.} This post is a senior-level, copy-pasteable tour. {Bài này là một tour cấp senior, copy-paste được ngay.}

Open the full demo {Mở demo đầy đủ}: /tools/css-starting-style-demo/.

The core problem: there is no “before” state

A transition animates between two known values. {Một transition chuyển động giữa hai giá trị đã biết.} When an element is freshly inserted into the DOM — or revealed from display: none — the browser has nothing to start from. {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.} It simply paints the final state immediately, with no animation. {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 solves exactly this: it declares the values to use for the very first style update of an element (or when it becomes rendered again). {@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

Think of @starting-style as the “from” keyframe for the entry transition. {Hãy coi @starting-style như keyframe “from” cho transition lúc vào.} The browser reads it once, paints that initial state, then transitions to the real rule. {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;
  }
}

Now when .toast enters the DOM, it starts at opacity: 0; translate: 0 1rem, then animates to its normal state. {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.} No JavaScript, no rAF hacks. {Không JavaScript, không hack rAF.}

Nested vs standalone @starting-style

There are two syntaxes, and the difference is about specificity and proximity, not behavior. {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;
  }
}

Both work. {Cả hai đều chạy.} The nested form is usually cleaner because the start state lives right next to the end state. {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.} One subtlety: the standalone @starting-style { .modal {} } has the same specificity as .modal, so source order can matter when rules tie. {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.} Keep @starting-style after the base rule it pairs with. {Hãy đặt @starting-style sau rule gốc mà nó đi kèm.}

The exit problem: animating back to display: none

Entry is half the story. {Vào chỉ là một nửa câu chuyện.} Exit is harder because hiding usually means display: none, and display is a discrete property — it flips instantly, killing your fade-out. {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 tells the browser to defer the discrete change until the transition finishes. {transition-behavior: allow-discrete bảo trình duyệt hoãn thay đổi discrete cho đến khi transition xong.} So display stays block while opacity animates to 0, then flips to none at the end. {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;
  }
}

Note display is listed in transition with allow-discrete. {Lưu ý display được liệt kê trong transition kèm allow-discrete.} Without it, the element vanishes before the fade can play. {Không có nó, phần tử biến mất trước khi fade kịp chạy.} You can also write the shorthand once: transition-behavior: allow-discrete; applies to all discrete properties in the same transition. {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

A full enter + exit animation needs all three working as a team. {Một animation vào + ra đầy đủ cần cả ba phối hợp như một đội.}

  1. @starting-style → the “from” state for entry. {@starting-style → trạng thái “from” cho lúc vào.}
  2. transition → interpolates the visible properties. {transition → nội suy các thuộc tính nhìn thấy được.}
  3. transition-behavior: allow-discrete → keeps the element rendered during exit. {transition-behavior: allow-discrete → giữ phần tử được render trong lúc ra.}

Recipe: <dialog> modal

The native <dialog> toggles between display: none and display: block, plus its backdrop. {<dialog> native chuyển giữa display: nonedisplay: block, cùng backdrop của nó.} This is the canonical @starting-style use case. {Đâ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();
});

The overlay property in the transition keeps the dialog in the top layer until the animation completes — critical for a smooth exit. {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

The Popover API gives you light-dismiss and top-layer behavior for free. {Popover API cho bạn light-dismiss và hành vi top-layer miễn phí.} Pair it with @starting-style for zero-JS animation. {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;
  }
}

The button uses popovertarget — no event listener required. {Nút dùng popovertarget — không cần event listener.} The browser handles open, close, Escape, and outside-click. {Trình duyệt lo việc mở, đóng, Escape, và click ra ngoài.}

Recipe: toast notification

A toast is inserted dynamically, so @starting-style shines here. {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);
}

For dynamically inserted nodes you still need a tiny bit of JS to remove them after the exit transition. {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 is the clean hook. {transitionend là hook gọn gàng nhất.}

Recipe: dropdown & tooltip with [hidden]

For non-top-layer UI, toggle a [hidden]-style attribute and let allow-discrete manage display. {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', ''));

Because display is in the transition with allow-discrete, the tooltip fades out before it is hidden, and @starting-style makes it fade in. {Vì 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-style and transition-behavior: allow-discrete shipped across Chrome/Edge (117+), Safari (17.4+), and Firefox (129+). {@starting-styletransition-behavior: allow-discrete đã có trên Chrome/Edge (117+), Safari (17.4+), và Firefox (129+).} Coverage is strong in 2024+, but progressive enhancement keeps older browsers safe. {Độ phủ tốt từ 2024 trở đi, nhưng progressive enhancement giữ an toàn cho trình duyệt cũ.}

The good news: this technique degrades gracefully. {Tin tốt: kỹ thuật này thoái lui mượt mà.} Browsers that don’t understand @starting-style simply skip the entry animation and show the final state — fully functional, just not animated. {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.}

If you need to branch logic, feature-detect with @supports. {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; }
}

@starting-style itself is hard to feature-detect directly, but since it is ignored gracefully you rarely need to. {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

Motion is an accessibility concern. {Chuyển động là vấn đề accessibility.} Always honor users who ask for less of it. {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;
  }
}

A near-zero duration keeps the discrete display handoff working (so exit still hides correctly) while removing perceptible motion. {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.} Avoid transition: none here, since it can break the allow-discrete timing. {Tránh transition: none ở đây, vì nó có thể phá timing của allow-discrete.}

Mental model & gotchas

  • @starting-style is entry only. Exit is handled by transition + allow-discrete. {@starting-style chỉ cho lúc vào. Lúc ra do transition + allow-discrete xử lý.}
  • The selector inside @starting-style must match the visible/open state (e.g. dialog[open], :popover-open). {Selector bên trong @starting-style phải khớp trạng thái hiện/mở (vd dialog[open], :popover-open).}
  • Always list display (and overlay for top-layer elements) in transition with allow-discrete. {Luôn liệt kê display (và overlay cho phần tử top-layer) trong transition kèm allow-discrete.}
  • For dynamically inserted nodes, you still need JS to remove them after transitionend. {Với node chèn động, bạn vẫn cần JS để xóa chúng sau transitionend.}
  • Keep nested @starting-style right inside the base rule for readability and predictable order. {Giữ @starting-style nested ngay trong rule gốc để dễ đọc và thứ tự dễ đoán.}

Takeaways

@starting-style finally gives CSS a first-class “from” state for entering elements. {@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.} Combined with transition-behavior: allow-discrete, you get full enter and exit animations for dialogs, popovers, toasts, dropdowns, and tooltips — with little or no JavaScript. {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.} It degrades gracefully and plays nicely with prefers-reduced-motion. {Nó thoái lui mượt và phối hợp tốt với prefers-reduced-motion.} Reach for the platform first. {Hãy ưu tiên dùng nền tảng trước.}