Skip to content

feat: Add expand/collapse animation support to Tree - #10378

Open
RobHannay wants to merge 4 commits into
adobe:mainfrom
RobHannay:rook/tree-expand-collapse-animations
Open

feat: Add expand/collapse animation support to Tree#10378
RobHannay wants to merge 4 commits into
adobe:mainfrom
RobHannay:rook/tree-expand-collapse-animations

Conversation

@RobHannay

@RobHannay RobHannay commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

There's no existing issue for this — happy to file one first if you'd prefer to discuss the approach before reviewing code. Related prior art: #7528 / #8867 (Disclosure) and #9077 (TabPanels).

Summary

Tree has no way to animate a subtree expanding or collapsing. Every other RAC component that hides content manages its own animation lifecycle — Modal, Popover and TabPanel hold the element in the DOM until getAnimations() settle; Disclosure keeps its panel mounted and defers the hidden attribute. Tree can't do either, because collapsing removes descendants from the collection immediately and they unmount synchronously.

This adds isEntering / isExiting render props and data-entering / data-exiting selectors to TreeItem, so rows can animate in and out.

Crucially, only the DOM lags — never the semantics. expandedKeys, aria-expanded, keyboard navigation and selection all reflect the collapse immediately. Exiting rows are inert, so they're removed from the accessibility tree and can't be focused or clicked while they animate away. This was the sticking point that makes the userland workaround (holding a key in expandedKeys for the duration of the animation) unacceptable: aria-expanded derives directly from state.expandedKeys, so a row would announce itself as expanded to a screen reader for the whole collapse.

How it works

Rendering and keyboard navigation are currently the same traversal, but only incidentally. Rendering iterates TreeCollection[Symbol.iterator] (Collection.tsx:216 iterates the collection object itself); navigation, range selection and End go through getKeyAfter / getKeyBefore / getLastKey. They agree only because the iterator delegates to getKeyAfter.

Splitting the two is the whole change:

  • TreeCollection gains a private renderedExpandedKeys, a superset of expandedKeys that also contains the parents of rows currently animating out. getKeyAfter is refactored into a private helper parameterised by the expanded set; the public key-stepping methods keep passing expandedKeys, and only the iterator (plus the section branch of getChildren, which renders via CollectionBranch rather than the iterator) passes the rendered set.
  • TreeInner tracks exitingKeys, diffed off lastExpandedKeys when a collapse happens, and drops keys again if an interrupted collapse makes them visible.
  • Each exiting row waits on its own animations via the existing useAnimation primitive and reports completion, releasing its key from the collection so it unmounts.

Entering is cheaper — the rows are already in the collection, so there's no collection work. The entering keys live in a ref rather than state, because rows read them once as they mount, in the same commit that expands their parent (same reasoning as the snapshot scope in SharedElementTransition). That also means defaultExpandedKeys doesn't animate the initial rows in, without needing the isInitiallySelected dance TabPanel does.

useAnimation is promoted from module-private to the existing react-aria/private/utils/animation subpath. It's reused rather than reimplemented so the interruption handling from #9772 (Promise.allSettled, cancellation flag, flushSync, JSDOM guard) applies here too.

Cost when you aren't animating

None. If no animation is declared, getAnimations() returns empty and useAnimation calls onEnd synchronously inside the layout effect, so the rows are released before paint — the collapse is indistinguishable from today's. There's a test pinning this.

Sizing

height: auto isn't animatable, so a row could otherwise only animate open or closed if the consumer hard-coded a row height — which rules out rows that size to their content. TreeItem therefore also publishes --tree-item-height, set to the row's measured height while it is entering or exiting and back to auto once the animation settles. This is the same polyfill useDisclosure applies to its panel in #8867.

It's gated the way TabPanels gates its measurement: the computed transition is probed once per row and the measurement is skipped entirely unless the row declares a transition on height / block-size / all. Rows that animate only opacity or transform never force a layout.

One wrinkle worth flagging in review: a row can't shrink below its own padding, so the examples animate padding-block alongside the height. I couldn't see a way around that without dictating the row's box model.

Known limitation

In a virtualized Tree, ListLayout doesn't re-measure rows mid-animation, so a height animation won't reflow the rows below it. Animate opacity / transform there instead. This is documented in the new Animation section of the Tree docs.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue. — no existing issue; happy to open one
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

Storybook: yarn start → React Aria Components / Tree / Animated expand/collapse.

Expand and collapse rows and check that:

  • Subtrees slide open and closed rather than snapping.
  • Interrupting a collapse by re-expanding mid-animation restores the rows smoothly rather than jumping.
  • Collapsing while a screen reader is on announces the row as collapsed straight away, and the animating rows are not reachable with / End.
  • Every other Tree story behaves exactly as before (no animation declared, so no hold).

Automated:

yarn jest packages/react-aria-components/test/Tree.test.tsx
yarn test:browser packages/react-aria-components/test/TreeAnimations.browser.test.tsx

Six new JSDOM tests cover the exit hold, immediate semantic collapse, keyboard exclusion, interruption, the enter case, and the no-animation fast path. Because JSDOM has no getAnimations, there are also three real-browser tests (TreeAnimations.browser.test.tsx) asserting the rows are genuinely mid-transition — those pass in Chromium, Firefox and WebKit.

🧢 Your Project:

Metaview


View in Rook
Rook session · Slack thread · Requested by Rob

RobHannay and others added 3 commits July 28, 2026 10:55
Poll for the state being asserted instead of sleeping for a fraction of the
transition, seek animations rather than sampling them mid-flight, and finish
them explicitly. Also wait a frame after reaching a state before mutating the
tree, since a transition is only generated when the property has a
before-change style from a completed style pass.

Co-authored-by: Rook <rook@rook.is>
…ht ♜

height: auto isn't animatable, so a row could only animate open or closed if the
consumer hard-coded a row height. Measure it instead and hand it to CSS as a
variable, the same polyfill useDisclosure applies to its panel, gated on the row
actually declaring a height transition.

Co-authored-by: Rook <rook@rook.is>
Reading the row mid-animation returns whatever its transitioned properties have
reached so far, not the values they're heading for. With the padding zeroed by
the entering state, that lands short, and the row jumps the difference when the
variable goes back to auto.

Measure with the entering/exiting overrides lifted and transitions suppressed,
and cache the result, since suppressing transitions is only safe while the row
is at rest.

Co-authored-by: Rook <rook@rook.is>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant