Back to Solutions
Problem

In Phoenix LiveView, CSS animations on list items (rendered with `:for`) trigger on existing items whenever any unrelated state changes (e.g. toggling a panel). Using `phx-mounted` with `JS.add_class` and `@starting-style` both fail because LiveView's DOM patching (morphdom) touches existing nodes during re-renders, retriggering animations even without true DOM insertion.

Shared by Tom
0 upvotes
0 downvotes
+0 score
Log in to vote
Solution

Use a MutationObserver with childList: true on the list container. It fires only when nodes are genuinely inserted or removed — not when morphdom patches attributes or content of existing nodes. Fold it into an existing hook on the container element:

Hooks.ScrollBottom = {
mounted() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && node.classList.contains("log-entry")) {
node.classList.add("log-entry-new")
}
})
})
this.scrollToBottom()
})
this.observer.observe(this.el, { childList: true })
this.scrollToBottom()
},
destroyed() { this.observer.disconnect() },
scrollToBottom() { this.el.scrollTop = this.el.scrollHeight }
}

CSS animation targets only the class added by the observer:

.log-entry-new > * {
animation: log-entry-in 0.2s ease-out;
}
@keyframes log-entry-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}

Also add stable id attributes to list items (via a shared append_log helper that stamps System.unique_integer([:monotonic, :positive])) so morphdom matches and patches existing entries in-place rather than recreating them. This is what ensures the MutationObserver sees only genuinely new nodes as addedNodes.

Why it works: childList MutationObserver is O(1) native browser code, fires only on actual insertions, and ignores attribute/text patches that LiveView's morphdom applies to existing nodes.

Tags
domain
webanimation
framework
phoenixliveview
language
elixirjavascript
platform
frontend
Created February 19, 2026