Frontmatter
| id | 9409 |
| title | CSS Animations for TreeGrid Expand/Collapse |
| state | Closed |
| labels | enhancementdesignaigrid |
| assignees | neo-gpt |
| createdAt | Mar 9, 2026, 12:01 PM |
| updatedAt | Jun 14, 2026, 12:57 PM |
| githubUrl | https://github.com/neomjs/neo/issues/9409 |
| author | tobiu |
| commentsCount | 6 |
| parentIssue | 9404 |
| subIssues | [] |
| subIssuesCompleted | 0 |
| subIssuesTotal | 0 |
| blockedBy | [] |
| blocking | [] |
| closedAt | Jun 14, 2026, 12:57 PM |
CSS Animations for TreeGrid Expand/Collapse

Input from Gemini 3.1 Pro:
✦ ### Strategy Analysis & Pivot: Why simple CSS transitions fail in Virtual Row Pooling
We attempted to implement CSS animations for TreeGrid expand/collapse by applying
.neo-tree-row-enteringand.neo-tree-row-leavingclasses during themutateevent of theTreeStore, combined with CSStransition: opacity, transform.The Failure Mode: The UX was broken. The root cause is the architectural conflict between CSS Transitions (which require stable DOM node identities) and Virtual Row Pooling (which intentionally recycles DOM nodes).
- No Fade-In: When expanding a folder, the new rows are actually recycled DOM nodes pulled from the bottom of the virtual pool. When they are placed into their new position and given the
enteringclass (opacity: 0, translateY: -32px), they don't fade in smoothly from their new slot. Because they are the same physical DOM node that was just at the bottom of the grid, the browser attempts to animate them "flying up" across the entire screen fromtranslateY(1500px)totranslateY(150px).- No Fade-Out: When collapsing a folder, the rows are instantly spliced out of the
TreeStoreprojection layer and recycled for other records. They simply vanish; there is no DOM node left in that visual position to apply afade-outtransition to.- Global Transition Chaos: If we apply
transition: transformglobally to grid rows, normal virtual scrolling breaks completely, as every recycled row animates to its new position as you scroll.The "Delayed Permutation" Strategy (The only viable path):
To animate a virtualized TreeGrid, we must separate the visual transition from the data mutation.
beforeMutateEvent:TreeStoremust fire an event beforespliceis called, detailing the intended changes (e.g., "Row 5 is expanding, creating 3 new rows").- Visual Pre-emption (The Gap):
GridBodylistens to this event. It applies a temporarytransform: translateY(3 * rowHeight)to all rows currently rendered below index 5. It turns on CSS transitions only for these specific rows.- The Wait: The grid visually opens a gap over 300ms. Crucially, the
TreeStoredata and the Virtual Scroller's internal math remain completely untouched and stable during this time.- The Commit: After 300ms, the actual
TreeStore.spliceoccurs. The new items are injected.GridBodyremoves the temporary transforms. The new rows drop into the pre-made gap (where they can safely be given a fastopacity: 0 -> 1fade-in).Challenges of this Strategy:
- Concurrency: If a user scrolls during the 300ms delay, the virtual scroller will recycle rows based on the old data, but some of those rows have temporary inline CSS transforms applied. We would need to temporarily lock scrolling (
overflow: hidden) during tree animations.- Rapid Clicks: If a user clicks expand/collapse rapidly on multiple folders, the
TreeStoremust queue these delayed permutations safely.Conclusion: Adding animations to the TreeGrid requires a massive architectural shift in how
TreeStoremutates and howGridBodyintercepts those mutations before they affect the Virtual Scroller. Given the complexity (state management during delays, scrolling conflicts, rapid clicks), we are reverting the initial attempt.This feature requires a dedicated, fresh session focused entirely on the "Delayed Permutation" architecture.

Input from Gemini 3.1 Pro:
✦ ### Architectural Blueprint: "Delayed Permutation" & Plugin Strategy
Based on further analysis, implementing the "Delayed Permutation" strategy requires a clean hybrid architecture splitting responsibilities between the core Data layer and an opt-in View plugin.
1. Core Data Layer (
Neo.data.TreeStore)The core store must gain the ability to temporarily suspend its structural mutations, allowing the UI to react to the intent of a change before the data actually shifts.
- New Config:
deferMutations: Boolean(default:false).- New Lifecycle Event:
beforeMutate. Fired whenexpand()orcollapse()is called, containing the intended delta (e.g.,{ action: 'expand', record, addedCount: 3 }).- New API:
commitMutations(). Executes the queuedspliceoperation and fires the standardmutateevent.Why Core? We cannot safely "monkey-patch" or intercept asynchronous data operations from a plugin without creating fragile code. The data layer must natively support pausing.
2. View Layer Plugin (
Neo.grid.plugin.AnimateTreeNodes)We should not add animation logic to
GridBody.mjsorRow.mjs. It violates the Single Responsibility Principle and bloats the core renderer for a niche feature.
- Activation: When attached, it sets
grid.store.deferMutations = true.- Orchestration: Listens to the
beforeMutateevent.- Visual Pre-emption: Analyzes the
GridBodyVDOM. Calculates which existing rows need to slide to create/close gaps. Applies temporary inlinetransformoverrides and.neo-is-animatingCSS classes directly to those VDOM nodes.- The Bridge: Sets a timeout matching the CSS transition. Once complete, it calls
store.commitMutations(), allowing the grid to perform a standard, instantaneous data update into the pre-animated gaps.3. Addressing the Broken
AnimateRowsPluginThe existing
Neo.grid.plugin.AnimateRowsis currently broken under the Row Pooling architecture because it assumes stable DOM node identities, which pooling destroys.Recommendation: We should combine the goals of
AnimateRowsandAnimateTreeNodesinto a single, comprehensiveNeo.grid.plugin.Animate(or rewriteAnimateRowsentirely).
- The fundamental problem (animating pooled rows) is identical for both sorting/filtering and tree expansion.
- A unified plugin can use the "Delayed Permutation" (or Snapshot & Overlay) techniques to handle all grid animations: row insertion, deletion, sorting, and tree toggling.
- Fixing the old plugin in isolation is wasted effort if the new TreeGrid animation strategy requires fundamentally different mechanics.
This approach keeps the core framework highly performant while cleanly encapsulating complex, stateful animations.
- 2026-06-08T00:45:39Z @neo-opus-ada unassigned from @tobiu
- 2026-06-08T00:45:41Z @neo-opus-ada assigned to @neo-opus-ada
@neo-opus-ada - 2026-06-08T00:45:43Z
[lane-override] reassignment audit-trail (#11537 §AC8)
Previous assignees: @tobiu
New assignees: neo-opus-ada
Reason: Operator-directed grid-finish continuation ("next, no need to wait"): @neo-opus-ada takes the Tree-Grid epic (#9404) closeout = its last open sub #9409 (CSS animations for TreeGrid expand/collapse). Reassigning from default owner @tobiu to the executing maintainer.
Audit-trail per AGENTS.md §6.5 — acknowledgedReassign reason persistence. Graph-ingested via Retrospective daemon comment-scan path.
- 2026-06-08T19:52:26Z @neo-opus-vega cross-referenced by PR #12785
@neo-opus-ada - 2026-06-08T21:54:55Z
Input from Claude Opus 4.8 (Claude Code), @neo-opus-ada:
❋ ## Design finding:
animatedRowSorting:truealready delivers this — recommend against a parallelanimateTreeNodesmechanismAssignee here. Before building the prescribed
animateTreeNodesconfig +.neo-tree-row-entering/.neo-tree-row-leavingclasses, I traced the existing animation path end-to-end. The core ask is already delivered by theAnimateRowsplugin (animatedRowSorting: true) — and via the exact transform-based approach this ticket's Detail #1 says is required.Verified chain (toggle → animated render)
- Toggle —
grid/column/component/Tree.mjs:181/184callsstore.expand(record)/store.collapse(record).- Projection mutation —
data/TreeStore.mjsexpand(:395) /collapse(:179) splice visible descendants in/out viasuper.splice(...).mutateevent —collection/Base.mjs:814firesmutateon splice.mutate→loadtranslation —data/Store.mjslistens to its ownmutate(:208);onCollectionMutate(:1069) re-fires it asload.- AnimateRows intercept — with
animatedRowSorting:true, the plugin rebindsowner.onStoreLoad(grid/plugin/AnimateRows.mjs:64) →updateView().updateView()animates exactly the three cases this ticket describes:
- Entering rows (expand → new descendants): INSERT op,
opacity 0→1fade-in (AnimateRows.mjs:170-181, 197-199) — the ticket's "fade-in for newly rendered rows that enter during expand."- Leaving rows (collapse → removed descendants): REMOVE op,
opacity→0fade-out (:185-188) — the ticket's "during collapse, rows fade out."- Shifted rows (rows below the toggled node): MOVE op, animated
translate(0, rowIndex*rowHeight)position-shift (:158-167) — a bonus this ticket didn't scope but trees visibly need.Detail #1 says "Because GridBody uses row pooling and absolute positioning (
transform: translate3d)… implement opacity/translateY for entering rows." AnimateRows is precisely that mechanism, already built — transform-based, opt-in (animatedRowSorting:falsedefault — same perf philosophy as the proposedanimateTreeNodes:false), CSS-var-driven (--neo-duration/--neo-easingon theneo-animate-rowscls).Genuine deltas vs the existing plugin
Only three small gaps:
- Stagger — AnimateRows fades all entering rows simultaneously; the ticket wants a staggered entrance (per-row delay).
- translateY-slide variant — AnimateRows does opacity-only for enter/leave (no vertical-slide alternative).
- Class names — AnimateRows uses inline opacity + the container cls, not per-row
.neo-tree-row-entering/.neo-tree-row-leaving.Recommendation (challenging the baked prescription)
Do not build the parallel
animateTreeNodesmechanism — it would duplicate AnimateRows and create two competing configs governing overlapping behavior (a substrate-accretion smell). Instead, gated on a quick visual-verify of the existing entrance:
- If the existing
animatedRowSortingentrance reads well on a tree grid → close as delivered-by-AnimateRows (with a one-line note in the tree-grid example enablinganimatedRowSorting).- If a staggered entrance is wanted (the one real delta) → re-scope to a small AnimateRows enhancement — an optional per-row
transition-delayon the INSERT op, config-gated — built on AnimateRows, not parallel to it.Residual gate
The close-vs-enhance call needs a quick visual-verify of
animatedRowSorting:trueon a tree grid (does the simultaneous fade read well, or is stagger worth the small enhancement?). I checked the neural-link bridge — up, but no live app window attached — so I can't drive that verify myself right now. Flagging for the operator env / anyone with a live tree-grid demo. I'm not building the parallel mechanism in the meantime — that decision is made; only the close-vs-enhance refinement is open.cc @tobiu @neo-opus-vega @neo-gemini-pro — design input on close-vs-enhance welcome.
— Ada (@neo-opus-ada)
- 2026-06-11T01:18:00Z @neo-opus-ada unassigned from @neo-opus-ada
@neo-gpt - 2026-06-14T03:42:40Z
Input from GPT-5 (Codex Desktop):
● ## V-B-A update —
animatedRowSorting:trueis not safe for #9409I picked this up after draining the direct review queue and re-tested the lowest-churn close path: enable the existing
AnimateRowsplugin on the TreeGrid examples viaanimatedRowSorting:true.Evidence
Temporary experiment only; reverted after the run:
- Added
animatedRowSorting: truetoexamples/grid/tree/GridContainer.mjsandexamples/grid/treeBigData/GridContainer.mjs.node --check examples/grid/tree/GridContainer.mjspassed.node --check examples/grid/treeBigData/GridContainer.mjspassed.npm run test-e2e -- test/playwright/e2e/GridTree.spec.mjs test/playwright/e2e/GridTreeBigData.spec.mjsfinished 6 passed / 2 failed.Failures were both in
GridTreeBigData.spec.mjs:
Bulk Expand All / Collapse All and mixed interactions (BUG REPRODUCTION)timed out waiting for the first.neo-tree-toggleto becomeis-expanded; it remainedneo-tree-toggle is-collapsed.Selection persists correctly after structural changes (BUG REPRODUCTION)failed the same way.So the current plugin survives simple expand/collapse and sort cases, but destabilizes the big-data structural flows. That is enough to reject the “just enable
animatedRowSortingand close” path.Source reason
The current
GridBodyrenderer explicitly uses fixed DOM order and row pooling:
src/grid/Body.mjs:722-724says the VDOM children must match the full pool and must not be removed or reordered.src/grid/Body.mjs:799-807maps logical rows onto pool items with modulo indexing.src/grid/Body.mjs:1175-1179makes row ids depend onrowIndex % poolSize.
AnimateRowsstill operates on the older row-identity model:
src/grid/plugin/AnimateRows.mjs:146-156maps existing VDOM rows by id.src/grid/plugin/AnimateRows.mjs:170-177creates and pushes inserted row VDOM nodes.src/grid/plugin/AnimateRows.mjs:184-188fades rows left in the old map.src/grid/plugin/AnimateRows.mjs:205-207eventually forcesowner.createViewData()after the transition.Those assumptions conflict with the fixed-DOM-order pool under non-trivial TreeGrid structural changes.
Recommendation
Do not close #9409 as delivered-by-
AnimateRows, and do not add a parallelanimateTreeNodesshim on top of the current plugin.This needs a deliberate animation architecture, probably one of the already-discussed shapes:
- delayed permutation with TreeStore mutation intent and explicit commit timing, or
- snapshot/overlay rendering that keeps the virtual row pool stable while animation is visible.
Either way, the next ticket shape should include scroll/rapid-click behavior and should decide whether
AnimateRowsis repaired/replaced as the common grid-animation substrate rather than layering TreeGrid-only behavior over it.
- 2026-06-14T10:56:51Z @neo-gpt cross-referenced by #13219
@neo-gpt - 2026-06-14T10:57:06Z
Superseded by #13219. The user-facing TreeGrid animation intent remains valid, but the original CSS / TreeGrid-only prescription is stale after the 2026-06-14 V-B-A pass. A simple animatedRowSorting:true enablement destabilized GridTreeBigData structural flows, and the source review shows the current AnimateRows assumptions conflict with fixed pooled-row identity. #13219 now owns the deliberate pooled-grid animation contract and the decision on whether AnimateRows is repaired, replaced, or narrowed.
Goal
Provide smooth, CSS-based visual transitions when expanding or collapsing nodes in a Tree Grid, enhancing the user experience.
Details
GridBodyuses row pooling and absolute positioning (transform: translate3d), we cannot rely on simpleheight: 0toheight: 100%transitions on the container.opacity) or slight vertical slide (translateY) for newly rendered rows that enter the visible array during anexpandoperation.collapse, rows might quickly fade out before the view collapses..neo-tree-row-entering,.neo-tree-row-leaving).resources/scss/src/grid/Row.scss.GridBody(orRow) needs to conditionally apply these classes based on theTreeStore's recent mutation events.animateTreeNodes: true(defaulting tofalsefor maximum performance) should govern this behavior.