Animation State Across Framework Re-renders

Part of Animation State Management in Core CSS Animation Fundamentals.

The problem

A list item fades in when it is added. Typing into a search box above the list re-renders the component on every keystroke, and every item fades in again. A progress ring’s rotation restarts from zero whenever its parent updates. A toast that should slide out simply vanishes, because the framework removed its element the moment its data disappeared.

The CSS is fine. What changes is the DOM underneath it: nodes replaced, classes toggled off and on, elements unmounted before their animation has anywhere to play.

Root cause analysis: animations belong to DOM nodes

A CSS animation is attached to a specific element and starts when its animation-name first applies to that element. Three framework behaviours interfere with that.

Node replacement restarts everything. If a framework decides an element is new — because its key changed, because its position in a list changed without keys, or because a parent component re-mounted — it creates a new DOM node. The new node starts its CSS animations from the beginning. Index-based keys in a filtered list are the most common cause: after filtering, item “b” gets the key that item “a” had, and the reconciliation shuffles nodes.

Class churn restarts animations. An animation restarts if its name is removed and re-applied in a way the browser observes. Some patterns — clearing className then setting it, or toggling an “animate” class in an effect on every render — cause exactly that. Stable, state-derived class lists do not.

Unmounting removes the node immediately. When state says an item is gone, the framework removes it from the DOM in the same commit. An exit animation has no element to run on. Exits need the element to remain mounted, in an “exiting” state, until the animation settles — the same completion problem described in handling end events reliably.

Why an item's entrance replays on every keystrokeThe animation is fine; the node is new each time.Why an item's entrance replays on every keystrokeInput changesListre-rendersfilteredKeys areindexesidentity lostNew DOM nodesreplacedEntrancereplaysper node
The animation is fine; the node is new each time.

Step-by-step resolution

Stabilising animated componentsMost fixes are about identity, not about animation code.Stabilising animated components1Give list items keys from data identity, never from array index.Nodes survive filtering and reordering2Derive animation classes and data-state from state, not from effects that toggle them.No class churn3Move long-running animations to elements that do not re-mount with frequent updates.Spinners keep their phase4For exits, keep removed items in a local 'exiting' list until their animation settles.Exit animations get to play5Store Animation objects in refs so re-renders do not recreate them.Script effects keep progress6Under reduced motion, unmount immediately and skip entrances.
Most fixes are about identity, not about animation code.

Production code pattern

// React-style list with stable keys and exit animations.
function Toasts({ toasts }) {
  const [exiting, setExiting] = useState([]);           // items removed from data, still animating out
  const previous = useRef(toasts);

  useEffect(() => {
    const removed = previous.current.filter((t) => !toasts.some((n) => n.id === t.id));
    if (removed.length) setExiting((e) => [...e, ...removed]);
    previous.current = toasts;
  }, [toasts]);

  const reduce = useReducedMotion();                    // matchMedia hook

  const onExitEnd = (id) => setExiting((e) => e.filter((t) => t.id !== id));

  return (
    <ol className="toasts">
      {toasts.map((t) => (
        <li key={t.id} className="toast" data-state="present">{t.message}</li>
      ))}
      {!reduce && exiting.map((t) => (
        <li key={t.id} className="toast" data-state="exiting" aria-hidden="true"
            onAnimationEnd={(e) => e.target === e.currentTarget && onExitEnd(t.id)}
            onAnimationCancel={(e) => e.target === e.currentTarget && onExitEnd(t.id)}>
          {t.message}
        </li>
      ))}
    </ol>
  );
}
.toast[data-state="present"] {
  animation: toast-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);   /* runs once per node */
}
.toast[data-state="exiting"] {
  animation: toast-out 180ms ease-in forwards;
  pointer-events: none;
}
@keyframes toast-in  { from { opacity: 0; translate: 0 8px; } }
@keyframes toast-out { to   { opacity: 0; translate: 0 8px; } }

@media (prefers-reduced-motion: reduce) {
  .toast[data-state] { animation: none; }
}

Rendering Impact: composite for the toasts; none from re-renders once identity is stable. Re-rendering a list with stable keys updates text in place and does not touch animation state, so typing elsewhere on the page produces no new animations.

A simplified detail worth noting: the exiting toast is rendered as a new element with the same key after the present one is removed. Frameworks that keep a node when the key persists across the switch would reuse it; if yours does not, render present and exiting items from one merged list so the node is kept and only data-state changes.

Entrance animations started while typing a 12-character searchList of 30 results; every keystroke re-renders the list.Entrance animations started while typing a 12-character searchIndex keys342 animationsStable id keys9 animationsStable keys, reduced motion0 animations
List of 30 results; every keystroke re-renders the list.

Where animation state should live

Framework state is for things that should re-render the view: whether a panel is open, which items exist. Animation progress is not one of them. Putting “animation is 40% complete” in component state triggers re-renders sixty times a second and fights the browser, which already tracks progress.

Keep the declarative intent in state — data-state="opening" — and let CSS or an Animation object stored in a ref own the timing. When a script effect must survive re-renders, create it once in an effect keyed on the element and store it in a ref; re-renders then read and control the same object instead of creating new ones.

Verification checklist

Constraints and trade-offs

  • Keeping exiting elements mounted briefly means the DOM temporarily differs from data; tests must account for it.
  • Libraries for transition groups solve the same problem with more generality and more code.
  • View Transitions can animate list changes by snapshot and avoid exit bookkeeping, at the cost of a different animation model.
  • Server-rendered markup that hydrates can restart entrances if hydration replaces nodes.
  • Stable keys do not help if a parent component is re-mounted; keep animated regions out of conditionally mounted wrappers.

Frequently asked questions

Why does my CSS entrance animation replay on every render?

The framework is creating new DOM nodes, usually because list keys are array indexes or a parent re-mounts. Use keys from item identity so nodes persist.

How do I animate an element that is being removed?

Keep it mounted in an exiting state until its exit animation ends or is cancelled, then remove it. Under reduced motion, remove it immediately.

Should animation progress be stored in component state?

No. Store intent such as open or closing in state, and let CSS or Animation objects in refs track progress.

Do view transitions remove the need for exit bookkeeping?

For many list and route changes, yes: the browser snapshots the old state and animates to the new one, so removed elements do not need to stay mounted.