From 9391cdf19389590e3d2ba81581dd4ed9cfe69632 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:37:18 -0700 Subject: [PATCH 1/3] fix: reset TabPanels size variables on TabPanelInner so nested Tabs do not inherit them --- packages/react-aria-components/src/Tabs.tsx | 6 ++ .../react-aria-components/test/Tabs.test.js | 102 ++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..fc93173cf1a 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -627,11 +627,17 @@ function TabPanelInner( let domProps = isSelected ? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps) : mergeProps(DOMProps, renderProps); + let style = { + '--tab-panel-width': 'auto', + '--tab-panel-height': 'auto', + ...renderProps.style + } as React.CSSProperties; return ( { expect(innerTabs[1]).toHaveTextContent('Two'); }); + it('resets tab panel transition variables for nested tabpanels', async () => { + let {getByTestId} = render( + + + Foo + Bar + + + + + + One + Two + + + One + Two + + + + Bar + + + ); + + // Wait a tick for MutationObserver in useHasTabbableChild to fire. + // This avoids React's "update not wrapped in act" warning. + await waitFor(() => Promise.resolve()); + + let outerTabPanels = getByTestId('outer-tabpanels'); + let outerTabPanel = getByTestId('outer-tabpanel'); + let innerTabPanels = getByTestId('inner-tabpanels'); + + expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px'); + expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe(''); + expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe(''); + }); + it('can add tabs and keep the current selected key', async () => { let onSelectionChange = jest.fn(); function Example(props) { @@ -829,6 +875,62 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); + it('should allow tab panel styles to override transition variable resets', () => { + let {getByTestId} = render( + + + A + B + + + + A + + B + + + ); + + let tabPanel = getByTestId('tabpanel'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('50px'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); + }); + + it('should merge tab panel transition variable resets with style render props', () => { + let {getByTestId} = render( + + + A + B + + + 'selected'} + data-testid="tabpanel" + style={() => ({ + opacity: 1 + })}> + A + + B + + + ); + + let tabPanel = getByTestId('tabpanel'); + expect(tabPanel).toHaveAttribute('class', 'selected'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(tabPanel).toHaveStyle({opacity: '1'}); + }); + it('should detect block-size in transition for TabPanels', async () => { let originalGetComputedStyle = window.getComputedStyle; window.getComputedStyle = el => ({ From 4bb8d2a82072f6318d931b6cf38b673052f8a805 Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Mon, 6 Jul 2026 08:18:01 -0700 Subject: [PATCH 2/3] Add NestedTabsSizeTransition story for the CSS variable bleed Address @snowystinger's request for a browser-testable story: the existing NestedTabs story does not apply the size-transition CSS, so it never exercises the --tab-panel-width/height inheritance. Add a story that puts an animated-tabpanels class (consuming those vars with a width/height transition) on both an outer and a nested TabPanels, so the bleed is observable in Storybook/Chromatic - without the per-TabPanel reset the inner panels inherit the outer TabPanels' pixel vars; with it they size to their own content. --- .../stories/Tabs.stories.tsx | 40 ++++++++++++++++++- .../react-aria-components/stories/styles.css | 8 ++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/stories/Tabs.stories.tsx b/packages/react-aria-components/stories/Tabs.stories.tsx index 55c7930d2ad..e7609eece7c 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,41 @@ export const NestedTabs: TabsStory = () => ( Bar ); + +// With the reset to auto on the enclosing TabPanel, inner TabPanels size to +// their own content. Without it, they inherit the outer TabPanels' pixel vars. +export const NestedTabsSizeTransition: TabsStory = () => ( + + + Nested tabs + Large panel + + + + + + One + Two + + + +
One
+
+ +
+ Two +
+ Small inner panel +
+
+
+
+
+ +
+ Large outer panel +
+
+
+
+); diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 7f8b969fa90..f04fac00ff3 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -22,6 +22,14 @@ } } +:global(.animated-tabpanels) { + display: block; + overflow: hidden; + width: var(--tab-panel-width); + height: var(--tab-panel-height); + transition: width 300ms, height 300ms; +} + .my-modal { position: fixed; top: 0; From 6fd08923110cd35f4c10906c1fcb147efb8d2a7e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:10:04 -0700 Subject: [PATCH 3/3] fix: register tab-panel size vars as non-inheriting, fall back to unset reset --- packages/react-aria-components/src/Tabs.tsx | 51 +++++++++++++++++-- .../stories/Tabs.stories.tsx | 8 ++- .../react-aria-components/test/Tabs.test.js | 14 ++--- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index fc93173cf1a..3eac788458d 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -251,6 +251,44 @@ export interface TabPanelRenderProps { export const TabsContext = createContext>(null); export const TabListStateContext = createContext | null>(null); +interface CSSPropertyDefinition { + name: string; + syntax: string; + inherits: boolean; + initialValue: string; +} + +interface CSSWithRegisterProperty { + registerProperty: (definition: CSSPropertyDefinition) => void; +} + +let supportsTabPanelSizePropertyRegistration = registerTabPanelSizeProperties(); + +function registerTabPanelSizeProperties(): boolean { + if ( + typeof CSS === 'undefined' || + typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function' + ) { + return false; + } + + let css = CSS as unknown as CSSWithRegisterProperty; + for (let name of ['--tab-panel-width', '--tab-panel-height']) { + try { + css.registerProperty({ + name, + syntax: '*', + inherits: false, + initialValue: 'auto' + }); + } catch { + continue; + } + } + + return true; +} + /** * Tabs organize content into multiple sections and allow users to navigate between them. */ @@ -627,11 +665,14 @@ function TabPanelInner( let domProps = isSelected ? mergeProps(DOMProps, tabPanelProps, focusProps, renderProps) : mergeProps(DOMProps, renderProps); - let style = { - '--tab-panel-width': 'auto', - '--tab-panel-height': 'auto', - ...renderProps.style - } as React.CSSProperties; + let style = renderProps.style; + if (!supportsTabPanelSizePropertyRegistration) { + style = { + '--tab-panel-width': 'unset', + '--tab-panel-height': 'unset', + ...renderProps.style + } as React.CSSProperties; + } return ( ( ); -// With the reset to auto on the enclosing TabPanel, inner TabPanels size to -// their own content. Without it, they inherit the outer TabPanels' pixel vars. +// With non-inheriting panel size variables, inner TabPanels size to their own content. +// Without it, they inherit the outer TabPanels' pixel vars. export const NestedTabsSizeTransition: TabsStory = () => ( @@ -175,9 +175,7 @@ export const NestedTabsSizeTransition: TabsStory = () => ( -
- Large outer panel -
+
Large outer panel
diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index b55555a5a12..9f248afd2b4 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -695,7 +695,7 @@ describe('Tabs', () => { expect(innerTabs[1]).toHaveTextContent('Two'); }); - it('resets tab panel transition variables for nested tabpanels', async () => { + it('falls back to resetting tab panel transition variables for nested tabpanels', async () => { let {getByTestId} = render( @@ -735,8 +735,8 @@ describe('Tabs', () => { expect(outerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe('320px'); expect(outerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe('240px'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); + expect(outerTabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); expect(innerTabPanels.style.getPropertyValue('--tab-panel-width')).toBe(''); expect(innerTabPanels.style.getPropertyValue('--tab-panel-height')).toBe(''); }); @@ -875,7 +875,7 @@ describe('Tabs', () => { expect(tabPanels).toHaveStyle({width: '100px'}); }); - it('should allow tab panel styles to override transition variable resets', () => { + it('should allow tab panel styles to override fallback transition variable resets', () => { let {getByTestId} = render( @@ -902,7 +902,7 @@ describe('Tabs', () => { expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('75px'); }); - it('should merge tab panel transition variable resets with style render props', () => { + it('should merge fallback transition variable resets with style render props', () => { let {getByTestId} = render( @@ -926,8 +926,8 @@ describe('Tabs', () => { let tabPanel = getByTestId('tabpanel'); expect(tabPanel).toHaveAttribute('class', 'selected'); - expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('auto'); - expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('auto'); + expect(tabPanel.style.getPropertyValue('--tab-panel-width')).toBe('unset'); + expect(tabPanel.style.getPropertyValue('--tab-panel-height')).toBe('unset'); expect(tabPanel).toHaveStyle({opacity: '1'}); });