diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
index 590a4ce1c26..1735db27bbe 100644
--- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
@@ -367,6 +367,43 @@ 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 the animation's target height (zero when collapsing, the measured CSS height
+when expanding), and back to `auto` once the animation finishes. Resting size changes are tracked with
+`ResizeObserver`. This is only measured when the row declares a transition on `height` or `all`.
+A row can't shrink below its own padding or minimum height, so animate those alongside the height.
+
+Expand/collapse animations are not supported in a [virtualized](Virtualizer) Tree. Virtualized rows
+may unmount before an animation completes, so expansion and collapse remain immediate there.
+Load-more indicators also disappear immediately when their parent collapses.
+
## 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 +518,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 target CSS height in pixels while expanding or collapsing, and auto otherwise. Set when height or all is used in the CSS transition."
}} />
### TreeItemContent
diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx
index ecc435879fe..d6ecdf57839 100644
--- a/packages/react-aria-components/src/Collection.tsx
+++ b/packages/react-aria-components/src/Collection.tsx
@@ -171,7 +171,7 @@ export interface CollectionBranchProps {
/** The parent node of the items to render. */
parent: Node;
/** A function that renders a drop indicator between items. */
- renderDropIndicator?: (target: ItemDropTarget) => ReactNode;
+ renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode;
}
export interface CollectionRootProps extends HTMLAttributes {
@@ -182,7 +182,7 @@ export interface CollectionRootProps extends HTMLAttributes {
/** A ref to the scroll container for the collection. */
scrollRef?: RefObject;
/** A function that renders a drop indicator between items. */
- renderDropIndicator?: (target: ItemDropTarget) => ReactNode;
+ renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode;
}
export interface CollectionRenderer {
@@ -210,7 +210,7 @@ export const DefaultCollectionRenderer: CollectionRenderer = {
function useCollectionRender(
collection: ICollection>,
parent: Node | null,
- renderDropIndicator?: (target: ItemDropTarget) => ReactNode
+ renderDropIndicator?: (target: ItemDropTarget, source?: Node) => ReactNode
) {
return useCachedChildren({
items: parent ? collection.getChildren!(parent.key) : collection,
@@ -229,7 +229,7 @@ function useCollectionRender(
return (
<>
- {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'})}
+ {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}, node)}
{rendered}
{renderAfterDropIndicators(collection, node, renderDropIndicator)}
>
@@ -241,7 +241,7 @@ function useCollectionRender(
export function renderAfterDropIndicators(
collection: ICollection>,
node: Node,
- renderDropIndicator: (target: ItemDropTarget) => ReactNode
+ renderDropIndicator: (target: ItemDropTarget, source?: Node) => ReactNode
): ReactNode {
let key = node.key;
let keyAfter = collection.getKeyAfter(key);
@@ -269,11 +269,14 @@ export function renderAfterDropIndicators(
(current.parentKey !== nextItemInFlattenedCollection.parentKey &&
nextItemInFlattenedCollection.level < current.level))
) {
- let indicator = renderDropIndicator({
- type: 'item',
- key: current.key,
- dropPosition: 'after'
- });
+ let indicator = renderDropIndicator(
+ {
+ type: 'item',
+ key: current.key,
+ dropPosition: 'after'
+ },
+ node
+ );
if (isValidElement(indicator)) {
afterIndicators.push(cloneElement(indicator, {key: `${current.key}-after`}));
}
diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx
index c2a05801f2b..0e911280f64 100644
--- a/packages/react-aria-components/src/Tree.tsx
+++ b/packages/react-aria-components/src/Tree.tsx
@@ -55,6 +55,7 @@ import {
forwardRefType,
GlobalDOMAttributes,
HoverEvents,
+ ItemDropTarget,
Key,
LinkDOMProps,
MultipleSelection,
@@ -99,6 +100,7 @@ import React, {
forwardRef,
JSX,
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,32 @@ 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 {useResizeObserver} from 'react-aria/private/utils/useResizeObserver';
import {useVisuallyHidden} from 'react-aria/VisuallyHidden';
+const emptyKeySet: Set = new Set();
+
+interface TreeAnimationContextValue {
+ exitingKeys: Set;
+ enteringKeys: Set;
+ renderedCollection: TreeCollection;
+ onExitComplete: (key: Key) => void;
+}
+
+const TreeAnimationContext = createContext(null);
+
class TreeCollection extends BaseCollection {
private expandedKeys: Set = new Set();
+ private renderedExpandedKeys: Set = new Set();
+ private exitingKeys: Set = emptyKeySet;
withExpandedKeys(lastExpandedKeys: Set, expandedKeys: Set) {
let collection = this.clone();
collection.expandedKeys = expandedKeys;
+ collection.renderedExpandedKeys = expandedKeys;
// 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
@@ -136,6 +155,36 @@ class TreeCollection extends BaseCollection {
return collection;
}
+ // Only the renderer sees retained rows. Behavioral consumers keep the semantic collection.
+ withExitingKeys(exitingKeys: Set) {
+ if (exitingKeys.size === 0) {
+ return this;
+ }
+
+ let collection = this.clone();
+ collection.expandedKeys = this.expandedKeys;
+ collection.renderedExpandedKeys = new Set(this.expandedKeys);
+ collection.exitingKeys = exitingKeys;
+ for (let key of exitingKeys) {
+ let parentKey = this.getItem(key)?.parentKey;
+ while (parentKey != null) {
+ collection.renderedExpandedKeys.add(parentKey);
+ parentKey = this.getItem(parentKey)?.parentKey;
+ }
+ }
+ TreeCollection.cloneAncestorSections(exitingKeys, emptyKeySet, collection);
+ collection.frozen = this.frozen;
+ return collection;
+ }
+
+ private isRendered(node: Node) {
+ return (
+ this.exitingKeys.size === 0 ||
+ this.exitingKeys.has(node.key) ||
+ isRowVisible(this, node.key, this.expandedKeys)
+ );
+ }
+
// diff lastExpandedKeys and expandedKeys so we only clone what has changed
private static cloneAncestorSections(
keys: Iterable,
@@ -163,13 +212,15 @@ class TreeCollection extends BaseCollection {
let node: Node | null = firstKey != null ? this.getItem(firstKey) : null;
while (node) {
- yield node as Node;
+ if (this.isRendered(node)) {
+ yield node as Node;
+ }
if (node.type === 'section') {
node = node.nextKey != null ? this.getItem(node.nextKey) : null;
} 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 +248,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;
}
@@ -257,9 +312,11 @@ class TreeCollection extends BaseCollection {
if (parent && parent.type === 'section' && node) {
// Stop once either the node is null or the node is the parent's sibling
while (node && node.key !== parent.nextKey) {
- yield self.getItem(node.key)!;
+ if (self.isRendered(node)) {
+ yield self.getItem(node.key)!;
+ }
// This will include content nodes which we skip in ListLayout
- let key = self.getKeyAfter(node.key);
+ let key = self.getKeyAfterInternal(node.key, self.renderedExpandedKeys);
node = key != null ? (self.getItem(key)! as CollectionNode) : null;
}
} else {
@@ -445,17 +502,65 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) {
let [lastCollection, setLastCollection] = useState(collection);
let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys);
+ let [exitingKeys, setExitingKeys] = useState(emptyKeySet);
+ let [enteringKeys, setEnteringKeys] = useState(emptyKeySet);
let [flattenedCollection, setFlattenedCollection] = useState(() =>
collection.withExpandedKeys(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));
+ let expandedKeysChanged = !areSetsEqual(lastExpandedKeys, expandedKeys);
+ if (expandedKeysChanged || collection !== lastCollection) {
+ let nextCollection = collection.withExpandedKeys(lastExpandedKeys, expandedKeys);
+ let previousVisible = getVisibleItemKeys(flattenedCollection);
+ let nextVisible = getVisibleItemKeys(nextCollection);
+ let nextExiting = new Set();
+ let nextEntering = new Set();
+ // Virtualized rows may never mount (or unmount before finishing), so they cannot own exits.
+ if (!isVirtualized) {
+ for (let key of new Set([...exitingKeys, ...previousVisible])) {
+ if (collection.getItem(key) && !nextVisible.has(key)) {
+ nextExiting.add(key);
+ }
+ }
+ if (expandedKeysChanged) {
+ nextEntering = new Set(
+ [...nextVisible].filter(key => !previousVisible.has(key) && !exitingKeys.has(key))
+ );
+ }
+ }
+ setFlattenedCollection(nextCollection);
setLastCollection(collection);
setLastExpandedKeys(expandedKeys);
+ setExitingKeys(nextExiting);
+ setEnteringKeys(nextEntering);
}
+ let onExitComplete = useCallback((key: Key) => {
+ setExitingKeys(keys => {
+ if (!keys.has(key)) {
+ return keys;
+ }
+ let next = new Set(keys);
+ next.delete(key);
+ return next;
+ });
+ }, []);
+
+ useLayoutEffect(() => {
+ if (enteringKeys.size > 0) {
+ setEnteringKeys(emptyKeySet);
+ }
+ }, [enteringKeys]);
+
+ let renderedCollection = useMemo(
+ () => flattenedCollection.withExitingKeys(exitingKeys),
+ [flattenedCollection, exitingKeys]
+ );
+ let animationContextValue = useMemo(
+ () => ({exitingKeys, enteringKeys, renderedCollection, onExitComplete}),
+ [exitingKeys, enteringKeys, renderedCollection, onExitComplete]
+ );
+
let state = useTreeState({
...props,
selectionMode,
@@ -608,6 +713,15 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) {
}
let DOMProps = filterDOMProps(props, {global: true});
+ let renderDropIndicator = useRenderDropIndicator(dragAndDropHooks, dropState);
+ let renderVisibleDropIndicator = useCallback(
+ (target: ItemDropTarget, source?: Node) => {
+ return isRowVisible(state.collection, source?.key ?? target.key, expandedKeys)
+ ? renderDropIndicator?.(target)
+ : null;
+ },
+ [state.collection, expandedKeys, renderDropIndicator]
+ );
return (
<>
@@ -635,20 +749,21 @@ function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) {
{hasDropHooks && }
@@ -686,6 +801,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 +929,18 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
props.hasChildItems || [...state.collection.getChildren!(item.key)]?.length > 1;
let level = rowProps['aria-level'] || 1;
+ let {exitingKeys, enteringKeys, onExitComplete} = useContext(TreeAnimationContext)!;
+ let isExiting = exitingKeys.has(item.key);
+ let [didEnterViaExpansion] = useState(() => enteringKeys.has(item.key));
+ let [isAnimationReady, setAnimationReady] = useState(!didEnterViaExpansion);
+ let isEntering = useEnterAnimation(ref, didEnterViaExpansion && isAnimationReady && !isExiting);
+ useTreeItemHeight(ref, isAnimationReady, setAnimationReady, 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 +996,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
selectionMode,
selectionBehavior,
isFocusVisibleWithin,
+ isEntering,
+ isExiting,
state,
id: item.key,
allowsDragging: !!dragState,
@@ -868,6 +1012,8 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
hasChildItems,
level,
isFocusVisibleWithin,
+ isEntering,
+ isExiting,
state,
item.key,
dragState,
@@ -942,7 +1088,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
return (
<>
- {dropIndicator && !dropIndicator.isHidden && (
+ {dropIndicator && !dropIndicator.isHidden && !isExiting && (
(props: TreeSectionProps, ref: ForwardedRef, item: Node) => {
let state = useContext(TreeStateContext)!;
+ let {renderedCollection} = useContext(TreeAnimationContext)!;
let {CollectionBranch} = useContext(CollectionRendererContext);
let headingRef = useRef(null);
ref = useObjectRef(ref);
@@ -1299,7 +1450,7 @@ export const TreeSection = /*#__PURE__*/ createBranchComponent(
[GridListHeaderContext, {...rowProps, ref: headingRef}],
[GridListHeaderInnerContext, {...rowHeaderProps}]
]}>
-
+
);
@@ -1316,6 +1467,95 @@ export const TreeHeader = (props: TreeHeaderProps): ReactNode => {
);
};
+function useTreeItemHeight(
+ ref: RefObject,
+ isReady: boolean,
+ setReady: (ready: boolean) => void,
+ isEntering: boolean,
+ isExiting: boolean
+) {
+ let restingHeight = useRef(null);
+ let isSized = useRef(false);
+ let [hasHeightTransition, setHasHeightTransition] = useState(false);
+
+ // Observe only the resting geometry. Initial entry is staged before applying animation styles,
+ // so measuring never needs to remove selectors or cancel the consumer's keyframes.
+ useResizeObserver({
+ ref: hasHeightTransition ? ref : undefined,
+ box: 'border-box',
+ onResize() {
+ if (ref.current && !isEntering && !isExiting && !isSized.current) {
+ restingHeight.current = window.getComputedStyle(ref.current).height;
+ }
+ }
+ });
+
+ useLayoutEffect(() => {
+ let element = ref.current;
+ if (!element || typeof element.getAnimations !== 'function') {
+ setReady(true);
+ return;
+ }
+
+ let style = window.getComputedStyle(element);
+ let durations = style.transitionDuration.split(',');
+ let delays = style.transitionDelay.split(',');
+ let transitionsHeight = style.transitionProperty
+ .split(',')
+ .some(
+ (property, index) =>
+ ['height', 'all'].includes(property.trim()) &&
+ (parseFloat(durations[index % durations.length]) > 0 ||
+ parseFloat(delays[index % delays.length]) > 0)
+ );
+ setHasHeightTransition(transitionsHeight);
+ if (!transitionsHeight) {
+ element.style.removeProperty('--tree-item-height');
+ isSized.current = false;
+ setReady(true);
+ return;
+ }
+
+ if (!isSized.current && (!isReady || (!isEntering && !isExiting))) {
+ restingHeight.current = style.height;
+ }
+ if (!isReady) {
+ if (!isSized.current) {
+ element.style.setProperty('--tree-item-height', '0px');
+ isSized.current = true;
+ }
+ setReady(true);
+ return;
+ }
+ if (!isExiting && !isSized.current) {
+ return;
+ }
+ if (isExiting && !isSized.current) {
+ element.style.setProperty('--tree-item-height', restingHeight.current || style.height);
+ window.getComputedStyle(element).height;
+ }
+
+ element.style.setProperty(
+ '--tree-item-height',
+ isExiting ? '0px' : restingHeight.current || 'auto'
+ );
+ isSized.current = true;
+ if (!isEntering && !isExiting) {
+ let canceled = false;
+ Promise.allSettled(element.getAnimations().map(a => a.finished)).then(() => {
+ if (!canceled) {
+ element.style.setProperty('--tree-item-height', 'auto');
+ isSized.current = false;
+ restingHeight.current = window.getComputedStyle(element).height;
+ }
+ });
+ return () => {
+ canceled = true;
+ };
+ }
+ }, [ref, isReady, setReady, isEntering, isExiting]);
+}
+
function areSetsEqual(a: Set, b: Set) {
if (a.size !== b.size) {
return false;
@@ -1328,3 +1568,37 @@ function areSetsEqual(a: Set, b: Set) {
}
return true;
}
+
+function getVisibleItemKeys(collection: TreeCollection) {
+ let keys = new Set();
+ let key = collection.getFirstKey();
+ while (key != null) {
+ if (collection.getItem(key)?.type === 'item') {
+ keys.add(key);
+ }
+ key = collection.getKeyAfter(key);
+ }
+ return keys;
+}
+
+function isRowVisible(
+ collection: Pick, 'getItem'>,
+ 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;
+}
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..2f3b0e46773 100644
--- a/packages/react-aria-components/test/Tree.test.tsx
+++ b/packages/react-aria-components/test/Tree.test.tsx
@@ -3130,6 +3130,194 @@ 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);
+ });
+
+ it('should not duplicate ancestor drop indicators while descendants exit', async () => {
+ mockAnimations();
+ function DraggableAnimatedTree({expandedKeys}: {expandedKeys: string[]}) {
+ let {dragAndDropHooks} = useDragAndDrop({
+ getItems: keys => [...keys].map(key => ({'text/plain': String(key)})),
+ onReorder: () => {}
+ });
+ return (
+ {}}>
+
+
+ Source
+
+
+
+
+
+ Root
+
+
+
+ Child
+
+
+
+
+ );
+ }
+ let {getByRole, getAllByRole, rerender} = render(
+
+ );
+ act(() => getByRole('button', {name: 'Drag Source'}).focus());
+ await user.keyboard('{Enter}');
+ act(() => jest.runAllTimers());
+ rerender();
+ act(() => jest.runAllTimers());
+ expect(rowElements().filter(row => row.hasAttribute('data-exiting'))).toHaveLength(1);
+ expect(getAllByRole('button', {name: 'Insert after Root'})).toHaveLength(1);
+ await finishAnimations();
+ expect(getAllByRole('button', {name: 'Insert after Root'})).toHaveLength(1);
+ await user.keyboard('{Escape}');
+ act(() => jest.runAllTimers());
+ });
+ });
});
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..3fc5a15bf85
--- /dev/null
+++ b/packages/react-aria-components/test/TreeAnimations.browser.test.tsx
@@ -0,0 +1,488 @@
+/*
+ * 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.
+ */
+
+import {afterEach, beforeEach, expect, it} from 'vitest';
+import {Button} from '../src/Button';
+import {createRoot, Root} from 'react-dom/client';
+import {ListLayout} from 'react-stately/useVirtualizerState';
+import React from 'react';
+import {Tree, TreeItem, TreeItemContent, TreeLoadMoreItem, TreeSection} from '../src/Tree';
+import {Virtualizer} from '../src/Virtualizer';
+
+const DURATION = 5000;
+const LINE_HEIGHT = 30;
+const PADDING = 5;
+const ROW_HEIGHT = LINE_HEIGHT + PADDING * 2;
+
+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,
+ children
+}: {
+ expandedKeys: string[];
+ children?: React.ReactNode;
+}) {
+ return (
+ {}}>
+
+
+
+ Root
+
+ {children ?? (
+ <>
+
+ 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;
+async function waitFor(condition: () => boolean, description: string) {
+ await expect.poll(condition, {message: description}).toBe(true);
+ // Establish a before-change style before the next expansion/collapse.
+ 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'
+ );
+
+ let exiting = exitingRows();
+ 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'
+ );
+
+ let children = exitingRows();
+ seekToMiddle(children);
+ let middleHeights = children.map(height);
+ expect(middleHeights.every(h => h > 0 && h < ROW_HEIGHT)).toBe(true);
+ root.render();
+ await waitFor(() => exitingRows().length === 0, 'the exiting state is cleared');
+
+ expect(rows()).toHaveLength(3);
+ expect(container.querySelectorAll('[inert]')).toHaveLength(0);
+ expect(rows().slice(1)).toEqual(children);
+ expect(children.every(isAnimating)).toBe(true);
+
+ await finishAnimations(rows());
+ await expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_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'
+ );
+
+ 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 expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]);
+});
+
+it('releases siblings independently without restoring a finished row', async () => {
+ root.render();
+ await waitFor(() => rows().length === 3, 'expanded rows mount');
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'children exit'
+ );
+ let [first, second] = exitingRows();
+ seekToMiddle([first, second]);
+ await finishAnimations([first]);
+ await expect.poll(() => first.isConnected).toBe(false);
+ expect(second).toHaveAttribute('data-exiting');
+ expect(second).toHaveAttribute('inert');
+ expect(height(second)).toBeGreaterThan(0);
+ await finishAnimations([second]);
+ await expect.poll(() => rows().length).toBe(1);
+});
+
+it('retains a grandchild after its parent has finished exiting', async () => {
+ let children = (
+
+
+ Branch
+
+
+ Leaf
+
+
+ );
+ root.render({children});
+ await waitFor(() => rows().length === 3, 'nested rows mount');
+ root.render({children});
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'nested rows exit'
+ );
+ let [branch, leaf] = exitingRows();
+ seekToMiddle([branch, leaf]);
+ await finishAnimations([branch]);
+ await expect.poll(() => branch.isConnected).toBe(false);
+ expect(leaf.isConnected).toBe(true);
+ expect(leaf).toHaveAttribute('inert');
+ await finishAnimations([leaf]);
+ await expect.poll(() => rows().length).toBe(1);
+});
+
+it('removes loaders immediately and does not reveal newly added collapsed children', async () => {
+ let children = (
+ <>
+
+ Child
+
+ {}}>
+ Loading
+
+ >
+ );
+ root.render({children});
+ await waitFor(() => rows().length === 2, 'children mount');
+ expect(container.textContent).toContain('Loading');
+ root.render({children});
+ await waitFor(
+ () => exitingRows().length === 1 && exitingRows().every(isAnimating),
+ 'child exits'
+ );
+ let [child] = exitingRows();
+ seekToMiddle([child]);
+ expect(container.textContent).not.toContain('Loading');
+ expect(container.querySelector('[data-testid="loadMoreSentinel"]')).toBeNull();
+ root.render(
+
+ {children}
+
+ New
+
+
+ );
+ await waitFor(() => exitingRows().length === 1, 'existing child remains exiting');
+ expect(container.textContent).not.toContain('New');
+ await finishAnimations([child]);
+ await expect.poll(() => rows().length).toBe(1);
+});
+
+it('releases independently animated rows inside a section', async () => {
+ let tree = (expandedKeys: string[]) => (
+ {}}>
+
+
+
+ Root
+
+
+ A
+
+
+ B
+
+
+
+
+ );
+ root.render(tree(['root']));
+ await waitFor(() => rows().length === 3, 'section rows mount');
+ root.render(tree([]));
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'section rows exit'
+ );
+ let [a, b] = exitingRows();
+ seekToMiddle([a, b]);
+ await finishAnimations([a]);
+ await expect.poll(() => a.isConnected).toBe(false);
+ expect(b).toHaveAttribute('inert');
+ await finishAnimations([b]);
+ await expect.poll(() => rows().length).toBe(1);
+});
+
+it('refreshes intrinsic height after content resizes', async () => {
+ root.render();
+ await waitFor(() => rows().length === 3, 'expanded rows mount');
+ let children = rows().slice(1);
+ for (let child of children) {
+ child.style.lineHeight = '60px';
+ }
+ await waitFor(() => children.every(row => height(row) === 70), 'content resizes');
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'children exit'
+ );
+ seekToMiddle(children);
+ expect(children.map(height)).toEqual([35, 35]);
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 0 && children.every(isAnimating),
+ 'collapse reverses'
+ );
+ await finishAnimations(children);
+ await expect.poll(() => children.map(height)).toEqual([70, 70]);
+});
+
+it('restores content-driven sizing when an entering transition is canceled', async () => {
+ root.render();
+ await waitFor(() => rows().length === 1, 'root mounts');
+ root.render();
+ await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter');
+ let children = rows().slice(1);
+ for (let child of children) {
+ child.style.transition = 'none';
+ child.getAnimations();
+ }
+ await expect
+ .poll(() => children.map(row => row.style.getPropertyValue('--tree-item-height')))
+ .toEqual(['auto', 'auto']);
+ for (let child of children) {
+ child.style.lineHeight = '60px';
+ }
+ await expect.poll(() => children.map(height)).toEqual([70, 70]);
+});
+
+it('measures the CSS height box rather than assuming border-box sizing', async () => {
+ style.textContent += '.animated-tree-item { box-sizing: content-box; border-block: 2px solid; }';
+ root.render();
+ await waitFor(() => rows().length === 1, 'root mounts');
+ root.render();
+ await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter');
+ let children = rows().slice(1);
+ expect(children.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual([
+ '30px',
+ '30px'
+ ]);
+ await finishAnimations(children);
+ await expect.poll(() => children.map(height)).toEqual([44, 44]);
+});
+
+it('does not cancel entering keyframes while measuring a height transition', async () => {
+ style.textContent += `
+ @keyframes tree-fade { from { opacity: 0; } to { opacity: 1; } }
+ .animated-tree-item[data-entering] { animation: tree-fade ${DURATION}ms linear; }
+ `;
+ root.render();
+ await waitFor(() => rows().length === 1, 'root mounts');
+ root.render();
+ await waitFor(
+ () =>
+ rows().length === 3 &&
+ rows()
+ .slice(1)
+ .every(row => row.hasAttribute('data-entering') && isAnimating(row)),
+ 'entering keyframes run'
+ );
+ let children = rows().slice(1);
+ seekToMiddle(children);
+ expect(children.map(row => Number(window.getComputedStyle(row).opacity))).toEqual([0.5, 0.5]);
+ expect(children.every(row => height(row) > 0)).toBe(true);
+ await finishAnimations(children);
+ await waitFor(
+ () => children.every(row => !row.hasAttribute('data-entering') && isAnimating(row)),
+ 'height transitions follow the keyframes'
+ );
+ await finishAnimations(children);
+ await expect.poll(() => children.map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]);
+});
+
+it('collapses virtualized branches without waiting for unmounted rows', async () => {
+ style.textContent += '[role="treegrid"] { height: 80px; width: 300px; overflow: auto; }';
+ let tree = (expandedKeys: string[]) => (
+
+
+ {Array.from({length: 100}, (_, i) => (
+
+ Child {i}
+
+ ))}
+
+
+ );
+ root.render(tree(['root']));
+ await waitFor(() => rows().length > 1, 'virtualized children mount');
+ expect(rows().length).toBeLessThan(101);
+ root.render(tree([]));
+ await expect.poll(() => rows().length).toBe(1);
+ expect(exitingRows()).toHaveLength(0);
+ expect(container.querySelector('[role="treegrid"]')!.scrollHeight).toBe(80);
+});
+
+it('does not clear an inert attribute supplied by the consumer', async () => {
+ root.render(
+
+
+ Child
+
+
+ );
+ await waitFor(() => rows().length === 2, 'inert child mounts');
+ expect(rows()[1]).toHaveAttribute('inert');
+});
+
+it('does not retain rows when transitions are disabled for reduced motion', async () => {
+ style.textContent += '.animated-tree-item { transition: none; }';
+ root.render();
+ await waitFor(() => rows().length === 3, 'expanded rows mount');
+ root.render();
+ await expect.poll(() => rows().length).toBe(1);
+ root.render();
+ await expect.poll(() => rows().slice(1).map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]);
+ expect(rows().some(isAnimating)).toBe(false);
+});
+
+it('prepares intrinsic sizing only once when StrictMode replays effects', async () => {
+ root.render(
+
+
+
+ );
+ await waitFor(() => rows().length === 1, 'root mounts in StrictMode');
+ root.render(
+
+
+
+ );
+ await waitFor(() => rows().length === 3 && rows().slice(1).every(isAnimating), 'children enter');
+ let children = rows().slice(1);
+ expect(children.map(row => row.style.getPropertyValue('--tree-item-height'))).toEqual([
+ '40px',
+ '40px'
+ ]);
+ seekToMiddle(children);
+ expect(children.map(height)).toEqual([20, 20]);
+ await finishAnimations(children);
+ await expect.poll(() => children.map(height)).toEqual([ROW_HEIGHT, ROW_HEIGHT]);
+});
+
+it('observes padding changes as well as content changes', async () => {
+ root.render();
+ await waitFor(() => rows().length === 3, 'children mount');
+ style.textContent +=
+ '.animated-tree-item:not([data-entering]):not([data-exiting]) { padding-block: 15px; }';
+ let children = rows().slice(1);
+ await waitFor(() => children.every(isAnimating), 'padding transitions start');
+ await finishAnimations(children);
+ await waitFor(
+ () => children.every(row => height(row) === 60),
+ 'padding increases the resting height'
+ );
+ root.render();
+ await waitFor(
+ () => exitingRows().length === 2 && exitingRows().every(isAnimating),
+ 'children exit'
+ );
+ seekToMiddle(children);
+ expect(children.map(height)).toEqual([30, 30]);
+});
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..b451a19c99c 100644
--- a/starters/docs/src/Tree.css
+++ b/starters/docs/src/Tree.css
@@ -69,6 +69,8 @@
align-items: center;
gap: var(--spacing-2);
min-height: var(--spacing-8);
+ 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 +81,22 @@
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);
+ &[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: