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 {} } có 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.}
@starting-style→ the “from” state for entry. {@starting-style→ trạng thái “from” cho lúc vào.}transition→ interpolates the visible properties. {transition→ nội suy các thuộc tính nhìn thấy được.}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: none và display: 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-style và transition-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-styleis entry only. Exit is handled bytransition+allow-discrete. {@starting-stylechỉ cho lúc vào. Lúc ra dotransition+allow-discretexử lý.}- The selector inside
@starting-stylemust match the visible/open state (e.g.dialog[open],:popover-open). {Selector bên trong@starting-stylephải khớp trạng thái hiện/mở (vddialog[open],:popover-open).} - Always list
display(andoverlayfor top-layer elements) intransitionwithallow-discrete. {Luôn liệt kêdisplay(vàoverlaycho phần tử top-layer) trongtransitionkèmallow-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 sautransitionend.} - Keep nested
@starting-styleright inside the base rule for readability and predictable order. {Giữ@starting-stylenested 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 và 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.}