@starting-style for List Item Insertion
Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs.
The problem
A comments list appends new comments as they arrive. Each should fade and slide in. The existing implementation adds an is-new class with a keyframe animation, then removes the class after the animation ends — a script step per comment and a restart glitch if the list re-renders. Loading a thread with fifty comments animates all fifty on first paint, which is slow and pointless. And when a comment is inserted at the top, every existing comment jumps down instantly while only the new one animates.
@starting-style handles the first part — entry without classes — natively. The other two need deliberate decisions.
Root cause analysis: first style, not first class
Transitions need a before-change style. Normally, an element that has just been inserted has no previous style, so there is nothing to transition from: it simply renders in its final state. @starting-style supplies a style to treat as the “before” state for an element’s first style update, so a transition runs from those values to the element’s normal styles.
It applies to newly rendered elements. A node inserted into the document, or an element going from display: none to rendered, gets its starting style once. A node that stays in the DOM and is merely updated does not. That is why @starting-style avoids the keyframe restart problem: a framework re-render that keeps nodes does not re-trigger the entry, while one that replaces nodes does — which is an identity problem, covered in animation state across framework re-renders.
Initial page load counts as insertion. Every element present in the first render is newly rendered, so @starting-style animates the whole initial list. Usually that is not wanted. Scoping the starting style behind a class that is added to the list after the first frame solves it.
Siblings are not animated. Inserting an item changes the layout position of items after it instantly. @starting-style only affects the new item. Animating the displacement needs a separate technique — FLIP, a view transition, or growing the new item’s height from zero so siblings are pushed smoothly.
Step-by-step resolution
Production code pattern
:root { interpolate-size: allow-keywords; } /* lets block-size animate from 0 to auto */
.comments.list-ready > .comment {
opacity: 1;
translate: 0 0;
block-size: auto;
overflow: clip;
transition:
opacity var(--motion-enter-duration) var(--motion-enter-easing),
translate var(--motion-enter-duration) var(--motion-enter-easing),
block-size var(--duration-200) var(--ease-standard);
}
@starting-style {
.comments.list-ready > .comment {
opacity: 0;
translate: 0 -8px;
block-size: 0; /* siblings are pushed smoothly */
}
}
/* Bursts: only the first few new items animate. */
.comments.list-ready > .comment.is-burst {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.comments.list-ready > .comment { transition: none; }
}
const list = document.querySelector('.comments');
requestAnimationFrame(() => list.classList.add('list-ready')); // initial items never animate
function addComments(nodes) {
nodes.forEach((node, i) => {
if (i >= 5) node.classList.add('is-burst'); // cap animated inserts per batch
list.prepend(node);
});
}
Rendering Impact: composite for the fade and slide; layout per frame for the brief
block-sizegrowth, which is what moves siblings without jumping. For long lists where layout per frame is too costly, drop the block-size transition and accept the sibling jump, or use a view transition.
The readiness class is added in requestAnimationFrame rather than immediately, so the first render completes without the starting style in scope. Adding it synchronously in the same task as the initial render would still animate the first paint.
Removals are the other half
@starting-style only covers entry. A comment deleted from the DOM disappears instantly, and nothing in CSS can animate an element that no longer exists. Either keep it in the DOM with an exiting state until an exit transition finishes, hide it with display: none plus transition-behavior: allow-discrete so it can transition out while still present, or wrap the removal in a view transition. The options are compared in exit animations for removed DOM elements.
Verification checklist
Constraints and trade-offs
@starting-stylesupport is recent; older browsers show items instantly, which is an acceptable fallback.- Frameworks that replace nodes on update will replay the entry; fix identity first.
block-sizegrowth costs layout per frame and depends oninterpolate-sizesupport.- Screen reader announcements for new content need a live region; the animation does not announce anything.
- Very rapid inserts can interrupt each other’s size transitions, causing brief uneven spacing.
Frequently asked questions
Why does my whole list animate on page load?
Every element in the initial render is newly rendered, so @starting-style applies to all of them. Scope the starting style behind a class added after the first frame.
Does @starting-style replay when a list re-renders?
Only if the framework replaces the DOM nodes. Updated nodes that stay in the document do not get their starting style again.
How do I stop existing items jumping when one is inserted above them?
Animate the new item’s block size from zero with interpolate-size, use FLIP on the siblings, or wrap the insertion in a view transition.
Can @starting-style animate removals?
No. It only defines entry styles. Removals need the element to stay present during an exit transition or a view transition.
Related
- CSS @starting-style & Entry Effects — the parent topic
- Staggering Animations with sibling-index() — spacing out a batch of entries
- Animating ARIA Live Region Updates — announcing inserted content