diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
index 590a4ce1c26..6412f012b6a 100644
--- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
@@ -367,6 +367,41 @@ import {TextField} from 'vanilla-starter/TextField';
```
+## Animation
+
+Rows revealed by expanding a parent are marked with the `isEntering` state, and rows hidden by collapsing a
+parent stay mounted with the `isExiting` state until their animations finish. Use the corresponding
+`data-entering` and `data-exiting` selectors to animate rows in and out.
+
+Expansion state itself is never delayed: `aria-expanded` updates immediately, and exiting rows are `inert`,
+so they are excluded from keyboard navigation and hidden from assistive technology while they animate away.
+
+```css render=false
+.react-aria-TreeItem {
+ height: var(--tree-item-height, auto);
+ overflow: clip;
+ transition: height 200ms, padding 200ms, opacity 200ms;
+
+ &[data-entering],
+ &[data-exiting] {
+ padding-block: 0;
+ opacity: 0;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+}
+```
+
+`height: auto` can't be animated, so while a row is entering or exiting the Tree sets
+`--tree-item-height` to its measured height in pixels, and back to `auto` once the animation finishes.
+This is only measured when the row declares a transition on `height`. A row can't shrink below its own
+padding, so animate that alongside the height.
+
+In a [virtualized](Virtualizer) Tree, the layout does not re-measure rows while they animate; animate
+`opacity` or `transform` instead of `height` there.
+
## Drag and drop
Tree supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Tree) to learn more.
@@ -481,7 +516,8 @@ function Example() {
links={docs.links}
showDescription
cssVariables={{
- '--tree-item-level': "The depth of the item within the tree. Useful to calculate indentation."
+ '--tree-item-level': "The depth of the item within the tree. Useful to calculate indentation.",
+ '--tree-item-height': "The item's intrinsic height in pixels while it is entering or exiting, and auto otherwise. Useful for animations. Set when height, block-size, or all is used in the CSS transition."
}} />
### TreeItemContent
diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx
index 85e47c5aaa0..5ad15eee198 100644
--- a/packages/react-aria-components/src/Tree.tsx
+++ b/packages/react-aria-components/src/Tree.tsx
@@ -98,7 +98,9 @@ import React, {
ForwardedRef,
forwardRef,
JSX,
+ MutableRefObject,
ReactNode,
+ useCallback,
useContext,
useEffect,
useMemo,
@@ -109,6 +111,7 @@ import {SelectionIndicatorContext} from './SelectionIndicator';
import {SharedElementTransition} from './SharedElementTransition';
import {TreeDropTargetDelegate} from './TreeDropTargetDelegate';
import {TreeState, useTreeState} from 'react-stately/useTreeState';
+import {useAnimation, useEnterAnimation} from 'react-aria/private/utils/animation';
import {useCachedChildren} from 'react-aria/private/collections/useCachedChildren';
import {useCollator} from 'react-aria/useCollator';
import {useControlledState} from 'react-stately/useControlledState';
@@ -116,16 +119,43 @@ import {useFocusRing} from 'react-aria/useFocusRing';
import {useGridListSection, useGridListSelectionCheckbox} from 'react-aria/useGridList';
import {useHover} from 'react-aria/useHover';
import {useId} from 'react-aria/useId';
+import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect';
import {useLocale} from 'react-aria/I18nProvider';
import {useObjectRef} from 'react-aria/useObjectRef';
import {useVisuallyHidden} from 'react-aria/VisuallyHidden';
+const emptyKeySet: Set = new Set();
+
+interface TreeAnimationContextValue {
+ /** Keys of rows that have been collapsed but are still mounted while they animate out. */
+ exitingKeys: Set;
+ /**
+ * Keys of rows that were just revealed by an expansion. Held in a ref rather than state because
+ * rows read it once as they mount, which happens in the same commit that expands their parent.
+ */
+ enteringKeysRef: MutableRefObject>;
+ /** Called by a row once its exit animation has finished, to release it from the collection. */
+ onExitComplete: (key: Key) => void;
+}
+
+const TreeAnimationContext = createContext(null);
+
class TreeCollection extends BaseCollection {
private expandedKeys: Set = new Set();
-
- withExpandedKeys(lastExpandedKeys: Set, expandedKeys: Set) {
+ // Superset of expandedKeys used only when producing the rendered rows. It additionally contains the
+ // ancestors of rows that are animating out, so those rows stay mounted after their parent collapses.
+ // Navigation, selection and the a11y tree deliberately continue to use expandedKeys, so a collapse is
+ // reflected immediately and exiting rows are unreachable while they animate.
+ private renderedExpandedKeys: Set = new Set();
+
+ withExpandedKeys(
+ lastExpandedKeys: Set,
+ expandedKeys: Set,
+ renderedExpandedKeys: Set = expandedKeys
+ ) {
let collection = this.clone();
collection.expandedKeys = expandedKeys;
+ collection.renderedExpandedKeys = renderedExpandedKeys;
// Clone ancestor section nodes so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will
// Without this change, the items won't expand and collapse when virtualized inside a section
@@ -169,7 +199,7 @@ class TreeCollection extends BaseCollection {
} else {
// This will include both item and content nodes
// We handle the content nodes in useCollectionRenderer and ListLayout
- let key = this.getKeyAfter(node.key);
+ let key = this.getKeyAfterInternal(node.key, this.renderedExpandedKeys);
node = key != null ? this.getItem(key) : null;
}
}
@@ -197,12 +227,16 @@ class TreeCollection extends BaseCollection {
}
getKeyAfter(key: Key) {
+ return this.getKeyAfterInternal(key, this.expandedKeys);
+ }
+
+ private getKeyAfterInternal(key: Key, expandedKeys: Set) {
let node = this.getItem(key) as CollectionNode;
if (!node) {
return null;
}
- if ((this.expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) {
+ if ((expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) {
return node.firstChildKey;
}
@@ -259,7 +293,9 @@ class TreeCollection extends BaseCollection {
while (node && node.key !== parent.nextKey) {
yield self.getItem(node.key)!;
// This will include content nodes which we skip in ListLayout
- let key = self.getKeyAfter(node.key);
+ // Sections render their rows via CollectionBranch rather than the collection iterator, so this
+ // walks the rendered set to keep exiting rows mounted inside a section as well.
+ let key = self.getKeyAfterInternal(node.key, self.renderedExpandedKeys);
node = key != null ? (self.getItem(key)! as CollectionNode) : null;
}
} else {
@@ -445,17 +481,72 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) {
let [lastCollection, setLastCollection] = useState(collection);
let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys);
+ let [exitingKeys, setExitingKeys] = useState(emptyKeySet);
+ let [lastExitingKeys, setLastExitingKeys] = useState(exitingKeys);
+ let enteringKeysRef = useRef(emptyKeySet);
let [flattenedCollection, setFlattenedCollection] = useState(() =>
collection.withExpandedKeys(lastExpandedKeys, expandedKeys)
);
+ // Rows are removed from the collection as soon as their parent collapses, which gives them no chance to
+ // animate out. Instead, keep them in the rendered part of the collection until their animations finish.
+ // Keyboard navigation, selection and aria-expanded continue to use expandedKeys, so the collapse is
+ // reflected immediately and only the DOM lags behind.
+ let expandedKeysChanged = !areSetsEqual(lastExpandedKeys, expandedKeys);
+ let nextExitingKeys = exitingKeys;
+ if (expandedKeysChanged) {
+ nextExitingKeys = getExitingKeys(collection, lastExpandedKeys, expandedKeys, exitingKeys);
+ // Written during render alongside the collection so rows can read it as they mount in this same
+ // commit. Same trade-off as the previous-size ref in TabPanels.
+ // oxlint-disable-next-line react/react-compiler, rsp-rules/pure-render
+ enteringKeysRef.current = getEnteringKeys(collection, lastExpandedKeys, expandedKeys);
+ }
+
// if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this
- if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) {
- setFlattenedCollection(collection.withExpandedKeys(lastExpandedKeys, expandedKeys));
+ if (
+ expandedKeysChanged ||
+ collection !== lastCollection ||
+ !areSetsEqual(lastExitingKeys, nextExitingKeys)
+ ) {
+ setFlattenedCollection(
+ collection.withExpandedKeys(
+ lastExpandedKeys,
+ expandedKeys,
+ withExitingAncestors(collection, expandedKeys, nextExitingKeys)
+ )
+ );
setLastCollection(collection);
setLastExpandedKeys(expandedKeys);
+ setLastExitingKeys(nextExitingKeys);
+ if (nextExitingKeys !== exitingKeys) {
+ setExitingKeys(nextExitingKeys);
+ }
}
+ let onExitComplete = useCallback((key: Key) => {
+ setExitingKeys(keys => {
+ if (!keys.has(key)) {
+ return keys;
+ }
+ let next = new Set(keys);
+ next.delete(key);
+ return next;
+ });
+ }, []);
+
+ // Entering keys are only meaningful for the commit that revealed them. Clearing afterwards stops a row
+ // that mounts later (e.g. scrolled into view in a virtualized tree) from replaying the enter animation.
+ useLayoutEffect(() => {
+ if (enteringKeysRef.current.size > 0) {
+ enteringKeysRef.current = emptyKeySet;
+ }
+ });
+
+ let animationContextValue = useMemo(
+ () => ({exitingKeys: nextExitingKeys, enteringKeysRef, onExitComplete}),
+ [nextExitingKeys, onExitComplete]
+ );
+
let state = useTreeState({
...props,
selectionMode,
@@ -635,6 +726,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) {
@@ -686,6 +778,21 @@ export interface TreeItemRenderProps extends ItemRenderProps {
* @selector [data-focus-visible-within]
*/
isFocusVisibleWithin: boolean;
+ /**
+ * Whether the tree item is currently entering, after its parent was expanded. Use this to apply
+ * animations.
+ *
+ * @selector [data-entering]
+ */
+ isEntering: boolean;
+ /**
+ * Whether the tree item is currently exiting, after its parent was collapsed. The row remains in
+ * the DOM until its animations complete, but is inert and excluded from keyboard navigation. Use
+ * this to apply animations.
+ *
+ * @selector [data-exiting]
+ */
+ isExiting: boolean;
/** The state of the tree. */
state: TreeState;
/** The unique id of the tree row. */
@@ -799,6 +906,19 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
props.hasChildItems || [...state.collection.getChildren!(item.key)]?.length > 1;
let level = rowProps['aria-level'] || 1;
+ let {exitingKeys, enteringKeysRef, onExitComplete} = useContext(TreeAnimationContext)!;
+ let isExiting = exitingKeys.has(item.key);
+ // Whether this row appeared because its parent was expanded, as opposed to being present on mount.
+ // Captured once so that defaultExpandedKeys doesn't animate the initial rows in.
+ let [didEnterViaExpansion] = useState(() => enteringKeysRef.current.has(item.key));
+ let isEntering = useEnterAnimation(ref, didEnterViaExpansion) && !isExiting;
+ useTreeItemHeight(ref, isEntering, isExiting);
+ useAnimation(
+ ref,
+ isExiting,
+ useCallback(() => onExitComplete(item.key), [onExitComplete, item.key])
+ );
+
let {hoverProps, isHovered} = useHover({
// because of https://bugs.webkit.org/show_bug.cgi?id=214609, supporting hover styles when a item is ONLY isDraggable
// results in hover styles sticking around after a reorder/drop operation...
@@ -854,6 +974,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
selectionMode,
selectionBehavior,
isFocusVisibleWithin,
+ isEntering,
+ isExiting,
state,
id: item.key,
allowsDragging: !!dragState,
@@ -868,6 +990,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
hasChildItems,
level,
isFocusVisibleWithin,
+ isEntering,
+ isExiting,
state,
item.key,
dragState,
@@ -991,6 +1115,12 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
data-focused={states.isFocused || undefined}
data-focus-visible={isFocusVisible || undefined}
data-pressed={states.isPressed || undefined}
+ data-entering={isEntering || undefined}
+ data-exiting={isExiting || undefined}
+ // Exiting rows are only still here so they can animate out. Keep them from being focused,
+ // clicked or announced while they do.
+ // @ts-ignore - compatibility with React < 19
+ inert={inertValue(isExiting)}
data-selection-mode={
state.selectionManager.selectionMode === 'none'
? undefined
@@ -1316,6 +1446,126 @@ export const TreeHeader = (props: TreeHeaderProps): ReactNode => {
);
};
+/**
+ * The row's height as if it weren't animating. The entering and exiting states commonly override
+ * the padding — a row can't shrink below it — and any height already applied here would clamp the
+ * result, so both are lifted for the duration of the read. Transitions are suppressed alongside
+ * them, or lifting an override would just start it animating and the read would return where it had
+ * got to rather than where it was heading. This runs inside a layout effect, so nothing is painted
+ * in between.
+ *
+ * Suppressing transitions cancels any that are in flight, so this must only be called while the row
+ * is at rest.
+ */
+function measureRestingHeight(element: HTMLElement) {
+ let entering = element.getAttribute('data-entering');
+ let exiting = element.getAttribute('data-exiting');
+ let height = element.style.getPropertyValue('--tree-item-height');
+ let transition = element.style.transition;
+
+ element.style.transition = 'none';
+ if (entering != null) {
+ element.removeAttribute('data-entering');
+ }
+ if (exiting != null) {
+ element.removeAttribute('data-exiting');
+ }
+ if (height) {
+ element.style.removeProperty('--tree-item-height');
+ }
+
+ let restingHeight = element.offsetHeight;
+
+ if (entering != null) {
+ element.setAttribute('data-entering', entering);
+ }
+ if (exiting != null) {
+ element.setAttribute('data-exiting', exiting);
+ }
+ if (height) {
+ element.style.setProperty('--tree-item-height', height);
+ }
+
+ // Settle the restored styles before transitions come back, so none of this animates.
+ element.offsetHeight;
+ element.style.transition = transition;
+
+ return restingHeight;
+}
+
+/**
+ * Publishes a row's intrinsic height as `--tree-item-height` while it animates in or out, so CSS
+ * can interpolate between zero and a real height. `height: auto` isn't animatable, and requiring a
+ * fixed row height would rule out rows that size to their content, so the value is measured here —
+ * the same polyfill `useDisclosure` applies to its panel.
+ *
+ * Only runs when the row actually declares a height transition, since measuring forces a layout.
+ */
+function useTreeItemHeight(
+ ref: RefObject,
+ isEntering: boolean,
+ isExiting: boolean
+) {
+ let hasHeightTransition = useRef(null);
+ let restingHeight = useRef(null);
+ let isSized = useRef(false);
+
+ useLayoutEffect(() => {
+ let element = ref.current;
+ if (!element || typeof element.getAnimations !== 'function') {
+ return;
+ }
+
+ if (hasHeightTransition.current == null) {
+ hasHeightTransition.current = /height|block-size|all/.test(
+ window.getComputedStyle(element).transition
+ );
+ }
+
+ if (!hasHeightTransition.current) {
+ return;
+ }
+
+ // `isSized` keeps this running for one more pass after an interrupted collapse, which leaves the row
+ // holding a pixel height it needs to grow back out of.
+ let isAtRest = !isEntering && !isExiting && !isSized.current;
+
+ // At rest nothing is in flight, so this is the only point the row can be measured honestly. The first
+ // pass of an entering row also qualifies: it has only just mounted, so its styles are its initial ones
+ // and no transition has started from them yet.
+ if (isAtRest || restingHeight.current == null) {
+ restingHeight.current = measureRestingHeight(element);
+ }
+
+ if (isAtRest) {
+ return;
+ }
+
+ let height = restingHeight.current + 'px';
+ // An interrupted collapse animates from wherever it got to, so it doesn't get a starting value.
+ let from = isExiting ? height : isEntering ? '0px' : null;
+ if (from != null) {
+ element.style.setProperty('--tree-item-height', from);
+
+ // Force style re-calculation to trigger animations.
+ window.getComputedStyle(element).height;
+ }
+
+ element.style.setProperty('--tree-item-height', isExiting ? '0px' : height);
+ isSized.current = true;
+
+ if (!isExiting) {
+ // After the animations complete, switch back to auto so the row can resize with its content.
+ Promise.all(element.getAnimations().map(a => a.finished))
+ .then(() => {
+ element.style.setProperty('--tree-item-height', 'auto');
+ isSized.current = false;
+ })
+ .catch(() => {});
+ }
+ }, [ref, isEntering, isExiting]);
+}
+
function areSetsEqual(a: Set, b: Set) {
if (a.size !== b.size) {
return false;
@@ -1328,3 +1578,118 @@ function areSetsEqual(a: Set, b: Set) {
}
return true;
}
+
+/** Adds every row that would be rendered beneath `key` for the given expanded set. */
+function addRenderedDescendants(
+ collection: BaseCollection,
+ key: Key,
+ expandedKeys: Set,
+ keys: Set
+) {
+ let node = collection.getItem(key) as CollectionNode | null;
+ let childKey = node?.firstChildKey ?? null;
+ while (childKey != null) {
+ let child = collection.getItem(childKey) as CollectionNode | null;
+ if (!child) {
+ break;
+ }
+
+ // Content nodes render inside their parent row rather than as rows of their own.
+ if (child.type !== 'content') {
+ keys.add(child.key);
+ if (child.type !== 'item' || expandedKeys.has(child.key)) {
+ addRenderedDescendants(collection, child.key, expandedKeys, keys);
+ }
+ }
+
+ childKey = child.nextKey;
+ }
+}
+
+/**
+ * Whether every ancestor of `key` is expanded, i.e. the row is part of the collapsed-aware
+ * collection.
+ */
+function isRowVisible(collection: BaseCollection, key: Key, expandedKeys: Set) {
+ let parentKey = collection.getItem(key)?.parentKey ?? null;
+ while (parentKey != null) {
+ let parent = collection.getItem(parentKey);
+ if (!parent) {
+ return false;
+ }
+
+ if (parent.type === 'item' && !expandedKeys.has(parent.key)) {
+ return false;
+ }
+
+ parentKey = parent.parentKey ?? null;
+ }
+
+ return true;
+}
+
+/**
+ * The rows that should keep rendering after a collapse. Rows revealed again by an interrupted
+ * collapse, and rows that have left the collection entirely, are dropped so they don't linger.
+ */
+function getExitingKeys(
+ collection: BaseCollection,
+ lastExpandedKeys: Set,
+ expandedKeys: Set,
+ exitingKeys: Set
+): Set {
+ let next = new Set(exitingKeys);
+ for (let key of lastExpandedKeys) {
+ if (!expandedKeys.has(key) && collection.getItem(key)) {
+ addRenderedDescendants(collection, key, lastExpandedKeys, next);
+ }
+ }
+
+ for (let key of next) {
+ if (!collection.getItem(key) || isRowVisible(collection, key, expandedKeys)) {
+ next.delete(key);
+ }
+ }
+
+ return next.size === 0 ? emptyKeySet : next;
+}
+
+/** The rows that a just-applied expansion revealed. */
+function getEnteringKeys(
+ collection: BaseCollection,
+ lastExpandedKeys: Set,
+ expandedKeys: Set
+): Set {
+ let keys = new Set();
+ for (let key of expandedKeys) {
+ if (!lastExpandedKeys.has(key) && collection.getItem(key)) {
+ addRenderedDescendants(collection, key, expandedKeys, keys);
+ }
+ }
+
+ return keys.size === 0 ? emptyKeySet : keys;
+}
+
+/**
+ * Expanded keys widened with the parents of every exiting row, so the render traversal can still
+ * reach them while they animate. Returns `expandedKeys` untouched when nothing is exiting.
+ */
+function withExitingAncestors(
+ collection: BaseCollection,
+ expandedKeys: Set,
+ exitingKeys: Set
+): Set {
+ if (exitingKeys.size === 0) {
+ return expandedKeys;
+ }
+
+ let keys = new Set(expandedKeys);
+ for (let key of exitingKeys) {
+ let parentKey = collection.getItem(key)?.parentKey;
+ if (parentKey != null) {
+ keys.add(parentKey);
+ }
+ }
+
+ return keys;
+}
diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx
index 8acea296766..a9847de73d2 100644
--- a/packages/react-aria-components/stories/Tree.stories.tsx
+++ b/packages/react-aria-components/stories/Tree.stories.tsx
@@ -2045,3 +2045,63 @@ export const VirtualizedTreeInShadowDOMStory: StoryObj): JSX.Element => (
+ <>
+
+
+
+ Photos
+
+
+
+
+ Projects-1A
+
+
+
+ Projects-2
+
+
+ Projects-3
+
+
+
+ >
+);
+
+export const AnimatedTree: StoryObj = {
+ render: args => ,
+ args: {
+ selectionMode: 'none',
+ selectionBehavior: 'toggle',
+ disabledBehavior: 'selection'
+ },
+ name: 'Animated expand/collapse'
+};
diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx
index c0087e78fd9..69be008e536 100644
--- a/packages/react-aria-components/test/Tree.test.tsx
+++ b/packages/react-aria-components/test/Tree.test.tsx
@@ -3130,6 +3130,146 @@ describe('Tree', () => {
});
});
});
+
+ describe('expand/collapse animations', () => {
+ let resolveAnimations: () => void;
+ let animation: {finished: Promise};
+
+ let mockAnimations = () => {
+ animation = {
+ finished: new Promise(resolve => {
+ resolveAnimations = resolve;
+ })
+ };
+ // useEnterAnimation checks `animation instanceof CSSTransition`, which JSDOM doesn't define.
+ // @ts-ignore
+ window.CSSTransition = window.CSSTransition ?? class CSSTransition {};
+ Element.prototype.getAnimations = jest.fn().mockImplementation(() => [animation]);
+ };
+
+ let finishAnimations = async () => {
+ await act(async () => {
+ resolveAnimations();
+ await animation.finished;
+ });
+ };
+
+ let rowElements = () =>
+ Array.from(document.querySelectorAll('.react-aria-TreeItem'));
+
+ afterEach(() => {
+ // @ts-ignore
+ delete Element.prototype.getAnimations;
+ });
+
+ it('should remove collapsed rows immediately when nothing is animating', async () => {
+ // No getAnimations (the JSDOM default) means there is nothing to wait for, so collapsing behaves
+ // exactly as it did before: the rows are gone by the time the collapse has been committed.
+ let {getAllByRole} = render();
+ expect(rowElements()).toHaveLength(6);
+
+ await user.click(within(getAllByRole('row')[1]).getAllByRole('button')[0]);
+
+ expect(rowElements()).toHaveLength(2);
+ expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0);
+ });
+
+ it('should keep collapsed rows mounted and inert until their animations finish', async () => {
+ mockAnimations();
+ let {getAllByRole} = render();
+ let rows = getAllByRole('row');
+ expect(rows).toHaveLength(6);
+
+ await user.click(within(rows[1]).getAllByRole('button')[0]);
+
+ // The four descendants of "projects" are still in the DOM, but marked as exiting and inert.
+ let exiting = rowElements().filter(row => row.hasAttribute('data-exiting'));
+ expect(exiting).toHaveLength(4);
+ expect(exiting.map(row => row.textContent)).toEqual(
+ expect.arrayContaining([expect.stringContaining('Projects-1A')])
+ );
+ for (let row of exiting) {
+ expect(row).toHaveAttribute('inert');
+ }
+
+ await finishAnimations();
+
+ expect(rowElements()).toHaveLength(2);
+ expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0);
+ });
+
+ it('should collapse semantically before the animation finishes', async () => {
+ mockAnimations();
+ let {getAllByRole} = render();
+ let rows = getAllByRole('row');
+
+ await user.click(within(rows[1]).getAllByRole('button')[0]);
+
+ // aria-expanded must not lie while the subtree animates away.
+ expect(rows[1]).toHaveAttribute('aria-expanded', 'false');
+ expect(rows[1]).not.toHaveAttribute('data-expanded');
+ expect(onExpandedChange).toHaveBeenCalledTimes(1);
+ expect(new Set(onExpandedChange.mock.calls[0][0])).toEqual(new Set(['projects-1']));
+ });
+
+ it('should exclude exiting rows from keyboard navigation', async () => {
+ mockAnimations();
+ let {getAllByRole} = render();
+ let rows = getAllByRole('row');
+
+ await user.click(within(rows[1]).getAllByRole('button')[0]);
+ expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(4);
+
+ act(() => rows[0].focus());
+ expect(document.activeElement).toBe(rows[0]);
+
+ // "projects" is the last navigable row even though its descendants are still rendered.
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toBe(rows[1]);
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toBe(rows[1]);
+ await user.keyboard('{End}');
+ expect(document.activeElement).toBe(rows[1]);
+ });
+
+ it('should clear the exiting state when a collapse is interrupted by re-expanding', async () => {
+ mockAnimations();
+ let {getAllByRole} = render();
+ let rows = getAllByRole('row');
+ let chevron = within(rows[1]).getAllByRole('button')[0];
+
+ await user.click(chevron);
+ expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(4);
+
+ await user.click(chevron);
+ expect(rowElements()).toHaveLength(6);
+ expect(document.querySelectorAll('[data-exiting]')).toHaveLength(0);
+ expect(document.querySelectorAll('[inert]')).toHaveLength(0);
+
+ // The now-stale animation completing must not remove the restored rows.
+ await finishAnimations();
+ expect(rowElements()).toHaveLength(6);
+ });
+
+ it('should mark rows as entering when revealed by an expansion, but not on mount', async () => {
+ mockAnimations();
+ let {getAllByRole} = render(
+
+ );
+ // defaultExpandedKeys must not animate the initial rows in.
+ expect(document.querySelectorAll('[data-entering]')).toHaveLength(0);
+
+ let rows = getAllByRole('row');
+ await user.click(within(rows[2]).getAllByRole('button')[0]);
+
+ let entering = rowElements().filter(row => row.hasAttribute('data-entering'));
+ expect(entering).toHaveLength(1);
+ expect(entering[0].textContent).toContain('Projects-1A');
+
+ await finishAnimations();
+ expect(document.querySelectorAll('[data-entering]')).toHaveLength(0);
+ });
+ });
});
AriaTreeTests({
diff --git a/packages/react-aria-components/test/TreeAnimations.browser.test.tsx b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx
new file mode 100644
index 00000000000..af2494a6326
--- /dev/null
+++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+// JSDOM has no getAnimations, so the exit hold can only be verified end to end in a real browser.
+//
+// Nothing here is timed against the wall clock. A concurrent root commits asynchronously and CI
+// machines are slow, so the transition is deliberately far longer than any plausible scheduling
+// delay, every observation polls for the state it is about to assert, and animations are finished
+// explicitly rather than waited out.
+
+import {afterEach, beforeEach, expect, it} from 'vitest';
+import {Button} from '../src/Button';
+import {createRoot, Root} from 'react-dom/client';
+import React from 'react';
+import {Tree, TreeItem, TreeItemContent} from '../src/Tree';
+
+const DURATION = 5000;
+const LINE_HEIGHT = 30;
+const PADDING = 5;
+const ROW_HEIGHT = LINE_HEIGHT + PADDING * 2;
+
+// Exercises the --tree-item-height polyfill rather than a hard-coded height: the row sizes to its content,
+// and the Tree publishes that height so it can be animated to and from zero. The padding is deliberate — a
+// row can't shrink below it, so it has to animate too, which in turn means a naive measurement taken while
+// the row is entering or exiting reads the wrong height.
+const css = `
+.animated-tree-item {
+ display: block;
+ box-sizing: border-box;
+ overflow: clip;
+ height: var(--tree-item-height, auto);
+ line-height: ${LINE_HEIGHT}px;
+ padding-block: ${PADDING}px;
+ transition: height ${DURATION}ms linear, padding ${DURATION}ms linear;
+}
+
+.animated-tree-item[data-entering],
+.animated-tree-item[data-exiting] {
+ padding-block: 0;
+}
+`;
+
+function AnimatedTree({expandedKeys}: {expandedKeys: string[]}) {
+ return (
+ {}}>
+
+
+
+ Root
+
+
+ Child 1
+
+
+ Child 2
+
+
+
+ );
+}
+
+let container: HTMLDivElement;
+let style: HTMLStyleElement;
+let root: Root;
+
+let rows = () => Array.from(container.querySelectorAll('.animated-tree-item'));
+let exitingRows = () => rows().filter(row => row.hasAttribute('data-exiting'));
+let isAnimating = (row: HTMLElement) => row.getAnimations().length > 0;
+let height = (row: HTMLElement) => row.getBoundingClientRect().height;
+let wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+/**
+ * Waits for a state and then for the browser to actually render it. A transition is only generated
+ * when the property has a before-change style from a completed style pass, so mutating the tree in
+ * the same frame the rows first appeared would silently produce no animation at all.
+ */
+async function waitFor(condition: () => boolean, description: string) {
+ for (let i = 0; i < 250 && !condition(); i++) {
+ await wait(20);
+ }
+
+ expect(condition(), description).toBe(true);
+ await new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ );
+}
+
+/** Seeks to the midpoint so the intermediate state can be asserted without racing the clock. */
+function seekToMiddle(elements: HTMLElement[]) {
+ for (let animation of elements.flatMap(el => el.getAnimations())) {
+ animation.pause();
+ animation.currentTime = DURATION / 2;
+ }
+}
+
+/** Jumps the elements' animations to their end rather than waiting out DURATION. */
+async function finishAnimations(elements: HTMLElement[]) {
+ let animations = elements.flatMap(el => el.getAnimations());
+ for (let animation of animations) {
+ animation.play();
+ animation.finish();
+ }
+
+ await Promise.all(animations.map(a => a.finished.catch(() => {})));
+}
+
+beforeEach(() => {
+ style = document.createElement('style');
+ style.textContent = css;
+ document.head.appendChild(style);
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ root.unmount();
+ container.remove();
+ style.remove();
+});
+
+it('keeps collapsed rows mounted until their exit transition finishes', async () => {
+ root.render();
+ await waitFor(() => rows().length === 3, 'three rows render while expanded');
+
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'both children are held in the DOM and animating out'
+ );
+
+ // The rows are still mounted and collapsing, but the tree already reports itself collapsed.
+ let exiting = exitingRows();
+ // The row sizes to its content, so it can only animate because the Tree published a height for it.
+ expect(exiting.every(row => row.style.getPropertyValue('--tree-item-height') !== '')).toBe(true);
+ seekToMiddle(exiting);
+ expect(exiting.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true);
+ expect(rows()[0]).toHaveAttribute('aria-expanded', 'false');
+ expect(exiting.every(row => row.hasAttribute('inert'))).toBe(true);
+
+ await finishAnimations(exiting);
+ await waitFor(() => rows().length === 1, 'the held rows are released once they finish animating');
+});
+
+it('restores rows when a collapse is interrupted by re-expanding', async () => {
+ root.render();
+ await waitFor(() => rows().length === 3, 'three rows render while expanded');
+
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'both children are held in the DOM and animating out'
+ );
+
+ root.render();
+ await waitFor(() => exitingRows().length === 0, 'the exiting state is cleared');
+
+ expect(rows()).toHaveLength(3);
+ expect(container.querySelectorAll('[inert]')).toHaveLength(0);
+
+ await finishAnimations(rows());
+ await waitFor(
+ () => rows().length === 3 && rows().every(row => height(row) === ROW_HEIGHT),
+ 'the restored rows animate back to full height'
+ );
+});
+
+it('animates rows in when they are revealed by an expansion', async () => {
+ root.render();
+ await waitFor(() => rows().length === 1, 'only the root renders while collapsed');
+
+ root.render();
+ await waitFor(
+ () => rows().length === 3 && rows().slice(1).every(isAnimating),
+ 'the revealed children are animating in'
+ );
+
+ // The published height must be the row's resting height. Measuring it naively reads the row mid-animation
+ // — with its padding already zeroed — and lands short, leaving the row to jump the difference at the end.
+ let revealed = rows().slice(1);
+ expect(revealed.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual(
+ revealed.map(() => `${ROW_HEIGHT}px`)
+ );
+
+ seekToMiddle(revealed);
+ expect(revealed.every(row => height(row) > 0 && height(row) < ROW_HEIGHT)).toBe(true);
+
+ await finishAnimations(rows());
+ await waitFor(
+ () => rows().every(row => height(row) === ROW_HEIGHT),
+ 'the revealed rows reach full height'
+ );
+});
diff --git a/packages/react-aria/exports/private/utils/animation.ts b/packages/react-aria/exports/private/utils/animation.ts
index fcb37101d2a..acccb0661cb 100644
--- a/packages/react-aria/exports/private/utils/animation.ts
+++ b/packages/react-aria/exports/private/utils/animation.ts
@@ -1 +1 @@
-export {useEnterAnimation, useExitAnimation} from '../../../src/utils/animation';
+export {useAnimation, useEnterAnimation, useExitAnimation} from '../../../src/utils/animation';
diff --git a/packages/react-aria/src/utils/animation.ts b/packages/react-aria/src/utils/animation.ts
index 226d88cd34e..57ae38791a2 100644
--- a/packages/react-aria/src/utils/animation.ts
+++ b/packages/react-aria/src/utils/animation.ts
@@ -80,7 +80,7 @@ export function useExitAnimation(ref: RefObject, isOpen: boo
return isExiting;
}
-function useAnimation(
+export function useAnimation(
ref: RefObject,
isActive: boolean,
onEnd: () => void
diff --git a/starters/docs/src/Tree.css b/starters/docs/src/Tree.css
index 7c73af4f445..3075815cfa4 100644
--- a/starters/docs/src/Tree.css
+++ b/starters/docs/src/Tree.css
@@ -69,6 +69,9 @@
align-items: center;
gap: var(--spacing-2);
min-height: var(--spacing-8);
+ /* Set to the row's intrinsic height while it animates in or out, and auto otherwise. */
+ height: var(--tree-item-height, auto);
+ overflow: clip;
padding: var(--spacing-1) var(--spacing-1) var(--spacing-1) var(--spacing-2);
box-sizing: border-box;
--padding: var(--spacing-4);
@@ -79,11 +82,25 @@
font: var(--font-size) system-ui;
position: relative;
transform: translateZ(0);
- transition-property: background, color, border-radius;
+ transition-property: background, color, border-radius, height, min-height, padding, opacity;
transition-duration: 200ms;
-webkit-tap-highlight-color: transparent;
--chevron-width: var(--spacing-5);
+ /* Rows stay mounted while their parent expands or collapses, so they can animate in and out. A row
+ can't shrink below its own padding, so that has to go with the height or it pops out rather than
+ closing. */
+ &[data-entering],
+ &[data-exiting] {
+ min-height: 0;
+ padding-block: 0;
+ opacity: 0;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+
&[data-has-child-items] {
--chevron-width: 0px;
}
diff --git a/starters/tailwind/src/Tree.tsx b/starters/tailwind/src/Tree.tsx
index 9a824087788..1bbaf8b9c42 100644
--- a/starters/tailwind/src/Tree.tsx
+++ b/starters/tailwind/src/Tree.tsx
@@ -15,7 +15,7 @@ import {composeTailwindRenderProps, focusRing} from './utils';
const itemStyles = tv({
extend: focusRing,
- base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg',
+ base: 'relative font-sans flex group gap-3 cursor-default select-none py-1 px-3 h-(--tree-item-height,auto) overflow-clip text-sm text-neutral-900 dark:text-neutral-200 bg-white dark:bg-neutral-900 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 -outline-offset-2 first:rounded-t-lg last:rounded-b-lg motion-safe:transition-[height,padding,opacity] motion-safe:duration-200 entering:py-0 entering:opacity-0 exiting:py-0 exiting:opacity-0',
variants: {
isSelected: {
false: