@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.

Which list items get the entry transitionOnly nodes rendered for the first time after the list is ready.Which list items get the entry transitionInitial renderList readyNode insertedNode updatedclass addedstarting style appliesno entry effect
Only nodes rendered for the first time after the list is ready.

Step-by-step resolution

Animated insertion without classes per itemOne starting style, one readiness class, one decision about siblings.Animated insertion without classes per item1Write the comment's resting style with a transition on opacity and translate.Final state is the base style2Add @starting-style inside a .list-ready scope with the entry values.Only post-load inserts animate3Add list-ready to the list in the frame after first render.Initial comments appear instantly4For top insertions, animate the new item's block size from 0 so siblings slide.No jump for existing comments5When more than a handful arrive together, skip the entry for the rest.Bursts do not cascade for seconds6Under reduced motion, remove transitions entirely.
One starting style, one readiness class, one decision about siblings.

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-size growth, 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.

Entry transitions started when loading a 50-comment thread and receiving 3 new commentsUnscoped starting style animates the initial render; scoped does not.Entry transitions started when loading a 50-comment thread and receiving 3 new commentsUnscoped @starting-style53 transitionsScoped behind list-ready3 transitionsScoped, reduced motion0 transitions
Unscoped starting style animates the initial render; scoped does not.

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-style support 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-size growth costs layout per frame and depends on interpolate-size support.
  • 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.