From d4de81d3e5d523b1e9ed1b28e2c917fb1bfa573a Mon Sep 17 00:00:00 2001 From: Jonathan Sabutis Date: Fri, 9 Oct 2026 09:33:47 -0700 Subject: [PATCH 1/3] fix: measure TabPanels after nested content renders and stop size variable inheritance TabPanels measured its new size in the same commit that mounted the new panel, before a nested Tabs had rendered its TabList and selected TabPanel, so it animated to the wrong height and snapped at the end. Measure in the next animation frame instead, which still runs before paint. Also define --tab-panel-width/height on every TabPanels so a nested TabPanels does not inherit the outer pixel values mid-transition. Fixes #10292 --- packages/react-aria-components/src/Tabs.tsx | 76 ++++++++++++------- .../stories/Tabs.stories.tsx | 50 +++++++++++- .../test/Tabs.browser.test.tsx | 63 ++++++++++++++- 3 files changed, 158 insertions(+), 31 deletions(-) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..c4cde0191c9 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -488,42 +488,52 @@ export const TabPanels = /*#__PURE__*/ createHideableComponent(function TabPanel ); } + let raf: number | undefined; if ( hasTransition.current && selectedKeyRef.current != null && selectedKeyRef.current !== state.selectedKey ) { - // Measure auto size. - el.style.setProperty('--tab-panel-width', 'auto'); - el.style.setProperty('--tab-panel-height', 'auto'); - let {width, height} = el.getBoundingClientRect(); - - if ( - prevSize.current && - (prevSize.current.width !== width || prevSize.current.height !== height) - ) { - // Revert to previous size. - el.style.setProperty('--tab-panel-width', prevSize.current.width + 'px'); - el.style.setProperty('--tab-panel-height', prevSize.current.height + 'px'); - - // Force style re-calculation to trigger animations. - window.getComputedStyle(el).height; - - // Animate to current pixel size. - el.style.setProperty('--tab-panel-width', width + 'px'); - el.style.setProperty('--tab-panel-height', height + 'px'); - - // When animations complete, revert back to auto size. - Promise.all(el.getAnimations().map(a => a.finished)) - .then(() => { - el.style.setProperty('--tab-panel-width', 'auto'); - el.style.setProperty('--tab-panel-height', 'auto'); - }) - .catch(() => {}); - } + // Wait until the next frame to measure, so that content which renders in a follow-up update + // (e.g. the TabList and TabPanel of a nested Tabs) is included. This runs before paint. + raf = requestAnimationFrame(() => { + // Measure auto size. + el.style.setProperty('--tab-panel-width', 'auto'); + el.style.setProperty('--tab-panel-height', 'auto'); + let {width, height} = el.getBoundingClientRect(); + + if ( + prevSize.current && + (prevSize.current.width !== width || prevSize.current.height !== height) + ) { + // Revert to previous size. + el.style.setProperty('--tab-panel-width', prevSize.current.width + 'px'); + el.style.setProperty('--tab-panel-height', prevSize.current.height + 'px'); + + // Force style re-calculation to trigger animations. + window.getComputedStyle(el).height; + + // Animate to current pixel size. + el.style.setProperty('--tab-panel-width', width + 'px'); + el.style.setProperty('--tab-panel-height', height + 'px'); + + // When animations complete, revert back to auto size. + Promise.all(el.getAnimations().map(a => a.finished)) + .then(() => { + el.style.setProperty('--tab-panel-width', 'auto'); + el.style.setProperty('--tab-panel-height', 'auto'); + }) + .catch(() => {}); + } + }); } selectedKeyRef.current = state.selectedKey; + return () => { + if (raf != null) { + cancelAnimationFrame(raf); + } + }; }, [ref, state.selectedKey]); // Store previous size before DOM updates occur. @@ -543,12 +553,20 @@ export const TabPanels = /*#__PURE__*/ createHideableComponent(function TabPanel let DOMProps = filterDOMProps(props, {labelable: true, global: true}); delete DOMProps.id; + // Define the size variables on every TabPanels so that a nested TabPanels does not inherit + // the pixel values of an outer TabPanels while it is animating. + let style = { + '--tab-panel-width': 'auto', + '--tab-panel-height': 'auto', + ...props.style + }; + return ( diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 55c7930d2ad..2c1a8c5ecd8 100644 --- a/packages/react-aria-components/stories/Tabs.stories.tsx +++ b/packages/react-aria-components/stories/Tabs.stories.tsx @@ -17,7 +17,7 @@ import {Orientation} from '@react-types/shared'; import {OverlayArrow} from '../src/OverlayArrow'; import React, {useState} from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; -import {Tab, TabList, TabPanel, TabProps, Tabs} from '../src/Tabs'; +import {Tab, TabList, TabPanel, TabPanels, TabProps, Tabs} from '../src/Tabs'; import {Tooltip, TooltipTrigger} from '../src/Tooltip'; import './styles.css'; @@ -144,3 +144,51 @@ export const NestedTabs: TabsStory = () => ( Bar ); + +const animatedPanelsStyle = { + height: 'var(--tab-panel-height)', + transition: 'height 400ms', + overflow: 'clip', + background: 'rgba(106, 90, 205, 0.1)' +}; + +const InnerTabs = () => ( + + + One + Two + + + +

Senatus Populusque Romanus.

+
+ +

Arma virumque cano, Troiae qui primus ab oris.

+

Italiam fato profugus, Laviniaque venit litora.

+

Multum ille et terris iactatus et alto.

+
+
+
+); + +export const NestedTabsAnimated: TabsStory = () => ( + + + Foo + Bar + Baz + + + +

Alea jacta est.

+
+ + + + +

Veni, vidi, vici.

+ +
+
+
+); diff --git a/packages/react-aria-components/test/Tabs.browser.test.tsx b/packages/react-aria-components/test/Tabs.browser.test.tsx index 8a778b0f008..2a1327f6307 100644 --- a/packages/react-aria-components/test/Tabs.browser.test.tsx +++ b/packages/react-aria-components/test/Tabs.browser.test.tsx @@ -11,9 +11,10 @@ */ import {expect, it} from 'vitest'; +import {page, userEvent} from 'vitest/browser'; import React from 'react'; import {render} from 'vitest-browser-react'; -import {Tab, TabList, TabPanel, Tabs} from '../src/Tabs'; +import {Tab, TabList, TabPanel, TabPanels, Tabs} from '../src/Tabs'; import {User} from '@react-aria/test-utils'; function TabsExample() { @@ -47,3 +48,63 @@ it.each` await tester.triggerTab({tab: tabs[1]}); expect(tester.getSelectedTab()).toBe(tabs[1]); }); + +const panelsStyle = { + height: 'var(--tab-panel-height)', + transition: 'height 500ms linear', + overflow: 'clip' +}; + +function NestedTabsExample() { + return ( + + + General + Nested + + + +
+ + + + + + One + + + Two + + + + +
+ + +
+ + + + + + + ); +} + +it('animates TabPanels to the full size of a nested Tabs', async () => { + await render(); + let outer = document.querySelector('[data-testid=outer]') as HTMLElement; + expect(outer.getBoundingClientRect().height).toBe(50); + + await userEvent.click(page.getByRole('tab', {name: 'Nested'})); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Mid transition, the outer panels should be animating towards the nested TabList + TabPanel, + // and the nested panels should not inherit the outer panels' size. + let inner = document.querySelector('[data-testid=inner]') as HTMLElement; + expect(outer.style.getPropertyValue('--tab-panel-height')).toBe('130px'); + expect(inner.getBoundingClientRect().height).toBe(100); + + await Promise.all(outer.getAnimations().map(a => a.finished)); + expect(outer.getBoundingClientRect().height).toBe(130); +}); From c8f884ac435e644b2dbf47480c1fdba6edb92fa7 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 21 Oct 2026 10:35:57 +1100 Subject: [PATCH 2/3] Make it easier for humans to look at the story --- .../stories/Tabs.stories.tsx | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 2c1a8c5ecd8..1c2322f2edb 100644 --- a/packages/react-aria-components/stories/Tabs.stories.tsx +++ b/packages/react-aria-components/stories/Tabs.stories.tsx @@ -172,23 +172,25 @@ const InnerTabs = () => ( ); export const NestedTabsAnimated: TabsStory = () => ( - - - Foo - Bar - Baz - - - -

Alea jacta est.

-
- - - - -

Veni, vidi, vici.

- -
-
-
+
+ + + Foo + Bar + Baz + + + +

Alea jacta est.

+
+ + + + +

Veni, vidi, vici.

+ +
+
+
+
); From cf6f1c25862cae2a54de627a8ddaeece0c383755 Mon Sep 17 00:00:00 2001 From: Jonathan Sabutis Date: Wed, 21 Oct 2026 15:44:58 -0700 Subject: [PATCH 3/3] chore: retrigger CLA check