Skip to content

fix: Reset TabPanels size variables on TabPanel so nested Tabs do not inherit them#10293

Open
mvanhorn wants to merge 5 commits into
adobe:mainfrom
mvanhorn:fix/10292-nested-tabs-css-variable-bleed
Open

fix: Reset TabPanels size variables on TabPanel so nested Tabs do not inherit them#10293
mvanhorn wants to merge 5 commits into
adobe:mainfrom
mvanhorn:fix/10292-nested-tabs-css-variable-bleed

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Nested TabPanels inherit the parent's inline --tab-panel-width / --tab-panel-height pixel values while they mount, because CSS custom properties inherit and unset cannot stop that (for inherited properties it means inherit). With the documented .react-aria-TabPanels { height: var(--tab-panel-height) } pattern, a freshly mounted inner TabPanels sizes itself from the outer panel's measurements and the height transition jumps.

TabPanelInner now sets --tab-panel-width: auto and --tab-panel-height: auto on its dom.div (the reset point snowystinger suggested in the issue), so the variables stop at the panel boundary and a nested TabPanels measures cleanly. User-provided inline styles are spread after the reset and still win. The two 0-height initial-render reports in the same issue are separate timing problems in the async collection build and are not touched here.

Closes #10292

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component). N/A: no documented behavior changes; the height-transition pattern in the TabPanels docs works as written once the variables stop inheriting.
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

Run yarn jest packages/react-aria-components/test/Tabs.test.js. The new tests render nested Tabs mid-transition and assert the inner panel's --tab-panel-width / --tab-panel-height resolve to auto rather than the outer panel's pixel values, and that a user style passed to TabPanel still overrides the reset. To reproduce manually, nest a Tabs inside a TabPanel with the height-transition CSS from the TabPanels docs and switch tabs: without the reset the inner panels flash to the outer panel's height.

🧢 Your Project:

Personal open source contribution

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did you verify that this worked in a browser to a satisfactory degree? I don't see a story to test it with.

Comment on lines +631 to +632
'--tab-panel-width': 'auto',
'--tab-panel-height': 'auto',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'--tab-panel-width': 'auto',
'--tab-panel-height': 'auto',
'--tab-panel-width': 'unset',
'--tab-panel-height': 'unset',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See reasoning here: #10292 (comment)

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.
@mvanhorn

mvanhorn commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Added a NestedTabsSizeTransition story (Tabs.stories.tsx) and ran it in Storybook locally to verify. The size-transition CSS (var(--tab-panel-width/height) with a transition) is applied to both an outer and a nested TabPanels.

Verified in the browser via DOM inspection: with the fix, every react-aria-TabPanel gets inline --tab-panel-width: auto; --tab-panel-height: auto, which breaks the custom-property inheritance so a nested TabPanels can't pick up the parent's transient pixel values; reverting the fix removes that reset.

One thing worth flagging: TabPanels only sets those vars during the resize animation and clears them at rest, so the bleed is a transition-time effect. The story exercises it when you switch tabs and watch the nested panel animate, rather than as a static snapshot difference. Happy to tune the scenario if you want it to show more starkly.

@devongovett

Copy link
Copy Markdown
Member

alternatively we could do CSS.registerProperty({name: '--tab-panel-width'}) (inherits is false by default).

@mvanhorn

mvanhorn commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Went with @devongovett's suggestion — registered --tab-panel-width/--tab-panel-height via CSS.registerProperty with inherits: false, so nested panels no longer inherit the outer panel's size vars. Kept @snowystinger's unset reset as a fallback for environments without CSS.registerProperty (SSR/jsdom), and updated the tests to match. Local jest (44/44), type-check, and format all pass. Thanks both.

@snowystinger

Copy link
Copy Markdown
Member

Looks like lint is failing. The commands to run are

yarn lint
yarn test

The agents sometimes reach a little too quickly for the direct binaries. I'm not sure why. I have a Claude.md file that I'm proposing which should help with that.

Fix the lint failure by formatting the multi-value transition
declaration the way Prettier expects.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Lint fixed in b805a3e - ran it via yarn per your note (fair point about agents reaching for the direct binaries; the Claude.md proposal sounds like the right fix). Both Tabs suites and the focused reruns stay green.

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried the fix in a browser? I attempted it with S2 and it seems to have the same problem as the stackblitz
maybe I missed something though

import {Checkbox, CheckboxGroup, Form, TextField, Radio, RadioGroup} from '../exports';
import {TabPanels} from 'react-aria-components/Tabs';

export const Repro: Story = {
  render: args => (
    <Tabs aria-label="Settings" UNSAFE_style={{alignSelf: 'stretch'}}>
      <TabList aria-label="Settings">
        <Tab id="general">General</Tab>
        <Tab id="appearance">Appearance</Tab>
        <Tab id="profile">Profile</Tab>
        <Tab id="notifications">Notifications</Tab>
      </TabList>
      <TabPanels
        style={{
          height: 'var(--tab-panel-height)',
          width: 'var(--tab-panel-width)',
          transition: 'height 400ms',
          overflow: 'clip'
        }}>
        <TabPanel id="general">
          <Form>
            <TextField label="Homepage" defaultValue="react-aria.adobe.com" />
            <Checkbox defaultSelected>Show sidebar</Checkbox>
            <Checkbox>Show status bar</Checkbox>
          </Form>
          <Tabs aria-label="Settings">
            <TabList aria-label="Settings">
              <Tab id="notifications">Notifications</Tab>
              <Tab id="profile">Profile</Tab>
            </TabList>
            <TabPanels
              style={{
                height: 'var(--tab-panel-height)',
                width: 'var(--tab-panel-width)',
                transition: 'height 400ms',
                overflow: 'clip'
              }}>
              <TabPanel id="notifications">
                <CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
                  <Checkbox value="account">Account activity</Checkbox>
                  <Checkbox value="mentions">Mentions</Checkbox>
                  <Checkbox value="dms">Direct message</Checkbox>
                  <Checkbox value="marketing">Marketing emails</Checkbox>
                </CheckboxGroup>
              </TabPanel>
              <TabPanel id="profile">
                <Form>
                  <TextField label="Name" defaultValue="Devon Govett" />
                  <TextField label="Username" defaultValue="@devongovett" />
                  <Button>Update profile</Button>
                </Form>
              </TabPanel>
            </TabPanels>
          </Tabs>
        </TabPanel>
        <TabPanel id="appearance">
          <Form>
            <RadioGroup label="Theme" defaultValue="auto">
              <Radio value="auto">Auto</Radio>
              <Radio value="light">Light</Radio>
              <Radio value="dark">Dark</Radio>
            </RadioGroup>
            <RadioGroup label="Font size" defaultValue="medium">
              <Radio value="small">Small</Radio>
              <Radio value="medium">Medium</Radio>
              <Radio value="large">Large</Radio>
            </RadioGroup>
          </Form>
          <Tabs aria-label="Settings" defaultSelectedKey="notifications">
            <TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
              <Tab id="notifications">Notifications</Tab>
              <Tab id="profile">Profile</Tab>
            </TabList>
            <TabPanels
              style={{
                height: 'var(--tab-panel-height)',
                width: 'var(--tab-panel-width)',
                transition: 'height 400ms',
                overflow: 'clip'
              }}>
              <TabPanel id="notifications">
                <CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
                  <Checkbox value="account">Account activity</Checkbox>
                  <Checkbox value="mentions">Mentions</Checkbox>
                  <Checkbox value="dms">Direct message</Checkbox>
                  <Checkbox value="marketing">Marketing emails</Checkbox>
                </CheckboxGroup>
              </TabPanel>
              <TabPanel id="profile">
                <Form>
                  <TextField label="Name" defaultValue="Devon Govett" />
                  <TextField label="Username" defaultValue="@devongovett" />
                  <Button>Update profile</Button>
                </Form>
              </TabPanel>
            </TabPanels>
          </Tabs>
        </TabPanel>
        <TabPanel id="profile">
          <Form>
            <TextField label="Name" defaultValue="Devon Govett" />
            <TextField label="Username" defaultValue="@devongovett" />
            <Button>Update profile</Button>
          </Form>
          <Tabs aria-label="Settings" defaultSelectedKey="profile">
            <TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
              <Tab id="notifications">Notifications</Tab>
              <Tab id="profile">Profile</Tab>
            </TabList>
            <TabPanels
              style={{
                height: 'var(--tab-panel-height)',
                width: 'var(--tab-panel-width)',
                transition: 'height 400ms',
                overflow: 'clip'
              }}>
              <TabPanel id="notifications">
                <CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
                  <Checkbox value="account">Account activity</Checkbox>
                  <Checkbox value="mentions">Mentions</Checkbox>
                  <Checkbox value="dms">Direct message</Checkbox>
                  <Checkbox value="marketing">Marketing emails</Checkbox>
                </CheckboxGroup>
              </TabPanel>
              <TabPanel id="profile">
                <Form>
                  <p>
                    Nested TabPanels should not inherit transition CSS variables from parent
                    TabPanels (perhaps TabPanel should reset them natively).
                  </p>
                  <TextField label="Name" defaultValue="Devon Govett" />
                  <TextField label="Username" defaultValue="@devongovett" />
                  <Button>Update profile</Button>
                </Form>
              </TabPanel>
            </TabPanels>
          </Tabs>
        </TabPanel>
        <TabPanel id="notifications">
          <Form>
            <TextField label="Name" defaultValue="Devon Govett" />
            <TextField label="Username" defaultValue="@devongovett" />
            <Button>Update profile</Button>
          </Form>
          <Tabs aria-label="Settings" defaultSelectedKey="profile">
            <TabList aria-label="Settings" UNSAFE_style={{minHeight: 40}}>
              <Tab id="notifications">Notifications</Tab>
              <Tab id="profile">Profile</Tab>
            </TabList>
            <TabPanels
              style={{
                height: 'var(--tab-panel-height)',
                width: 'var(--tab-panel-width)',
                transition: 'height 400ms',
                overflow: 'clip'
              }}>
              <TabPanel id="notifications">
                <CheckboxGroup label="Notifications settings" defaultValue={['account', 'dms']}>
                  <Checkbox value="account">Account activity</Checkbox>
                  <Checkbox value="mentions">Mentions</Checkbox>
                  <Checkbox value="dms">Direct message</Checkbox>
                  <Checkbox value="marketing">Marketing emails</Checkbox>
                </CheckboxGroup>
              </TabPanel>
              <TabPanel id="profile">
                <Form>
                  <p>
                    Nested TabPanels should not inherit transition CSS variables from parent
                    TabPanels (perhaps TabPanel should reset them natively).
                  </p>
                  <TextField label="Name" defaultValue="Devon Govett" />
                  <TextField label="Username" defaultValue="@devongovett" />
                  <Button>Update profile</Button>
                </Form>
              </TabPanel>
            </TabPanels>
          </Tabs>
        </TabPanel>
      </TabPanels>
    </Tabs>
  ),
  parameters: {
    layout: 'fullscreen'
  }
};

expect(innerTabs[1]).toHaveTextContent('Two');
});

it('falls back to resetting tab panel transition variables for nested tabpanels', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests aren't necessary, they can't actually test anything


// 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 = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This story is really hard to use due to the scrolling container that centers it.

Comment on lines +254 to +263
interface CSSPropertyDefinition {
name: string;
syntax: string;
inherits: boolean;
initialValue: string;
}

interface CSSWithRegisterProperty {
registerProperty: (definition: CSSPropertyDefinition) => void;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary

Comment on lines +270 to +275
typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function'
) {
return false;
}

let css = CSS as unknown as CSSWithRegisterProperty;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to work just fine?

Suggested change
typeof (CSS as unknown as CSSWithRegisterProperty).registerProperty !== 'function'
) {
return false;
}
let css = CSS as unknown as CSSWithRegisterProperty;
typeof CSS.registerProperty !== 'function'
) {
return false;
}
let css = CSS;

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Verified in Chrome against this branch using your exact S2 Repro story. What I observed:

  • registerProperty is active (re-registering --tab-panel-height throws "already registered")
  • Mid-transition the outer TabPanels carries --tab-panel-height: 446px inline while the nested TabPanels computes auto for the same property, so the bleed from Nested Tabs height transitions break due to CSS variable bleed and 0-height behavior of TabList and TabPanel #10292 is gone
  • The outer height interpolates smoothly (454 -> 432 -> 371 -> 300 -> 245 -> 206px across the 400ms window) and the inline var reverts to auto when the transition ends
  • Switching nested tabs animates the nested panel independently (268px -> 150px) with the outer panel untouched

One thing worth flagging: while verifying I initially reproduced something that looked exactly like the stackblitz bug, and it turned out to be an artifact of the tab being backgrounded - document.timeline freezes, the transition never finishes, and the inline px var never reverts, leaving the panel pinned and clipping content until the tab is foregrounded. If your S2 attempt was in a background tab or on another Space at any point, that could explain it. If not, could you share which browser and what you saw exactly?

Happy to add your S2 Repro as a story on this PR, apply the registerProperty simplification you suggested, and drop the tests you flagged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested Tabs height transitions break due to CSS variable bleed and 0-height behavior of TabList and TabPanel

4 participants