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.
Step-by-step resolution
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.
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.
Related
- Animation State Management — the parent topic
- Exit Animations for Removed DOM Elements — framework-independent exit patterns
- Integrating View Transitions with Client-Side Routers — snapshot-based alternatives