Skip to content

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

Open
mvanhorn wants to merge 7 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 7 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.

@snowystinger

Copy link
Copy Markdown
Member

Seen in Chrome, Safari, and FF. I'm not running it on another workspace or in another tab that doesn't have focus. I'm not sure how I'd see it if I was? Seems like the kind of thing an Agent could do and "see" though.

Tabs.resize.stutter.mov

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks for the video, that is clearly still bleeding. My check was against the S2 Repro story in Chrome and the outer and nested --tab-panel-height came out independent, so we are diverging somewhere in setup rather than in browser.

Before I chase the wrong thing: is that recording the S2 Repro story or a different one, and is it dev or the built storybook? I will reproduce exactly that and report back.

@snowystinger

Copy link
Copy Markdown
Member

The storybook setup I used was #10293 (review)
and it was dev

… them

Drops the CSS.registerProperty path in favour of an unconditional inline reset
on TabPanel.

The previous fallback used 'unset', which is a no-op here: custom properties
are inherited, so unset computes to inherit and the outer TabPanels' pixel
values still bled through. Any browser without CSS.registerProperty got no fix
at all. 'auto' actually resets, and matches the value TabPanels settles on at
rest.

Registering the properties also mutated the document's property registry on
import, which made --tab-panel-width/height non-inheriting for consumer CSS
too, a wider behaviour change than this fix needs.

Tests now assert the reset itself rather than the feature-detection branch, and
fail both when the reset is removed and when it is set back to 'unset'.
Anchor the story top left so the size transition is watchable in the centering
storybook container.
@mvanhorn

mvanhorn commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Your video was right, and chasing it found a real bug in my fix.

First, what I could and could not reproduce. I put your exact Repro story in the S2 storybook and ran it in Chrome in dev. To make sure I could actually detect the bleed rather than just fail to see it, I ran it twice: once with the fix reverted, once with it applied. Same story, same harness, same synthetic click.

fix reverted: inner TabPanels computed --tab-panel-height 446px, height 343px
fix applied:  inner TabPanels computed auto, height 150px

So the control does show the bug, and the registerProperty version does stop it in Chrome. I could not reproduce what your video shows, and I still cannot explain it.

But that experiment turned up something worse. The fallback for browsers without CSS.registerProperty used unset, and unset does nothing here. Custom properties are inherited, so unset computes to inherit. I checked it directly:

parent --probe: 999px, child sets --probe: unset
child computed --probe: 999px, and height: var(--probe) resolves to 999px

So on any browser without registerProperty the fix was doing literally nothing, which fits you seeing it in more than one browser. That was my bug, and the tests did not catch it because they asserted the string unset was present rather than that anything was reset. Your comment that those tests could not test anything was correct.

So I dropped registerProperty and made the reset unconditional with auto, which is the value TabPanels settles on at rest:

style={{'--tab-panel-width': 'auto', '--tab-panel-height': 'auto', ...renderProps.style}}

That works in every browser, needs no feature detection, and removes the two bits you called unnecessary along with the casts. It also drops a side effect I should not have added: registerProperty mutated the document property registry on import, so --tab-panel-width/height became non-inheriting for consumer CSS too, which is broader than this fix needs.

The tests now assert the reset instead of the branch. I checked they fail when the reset is removed and when it is set back to unset, so they are actually falsifiable this time. I also anchored the story top left, since the storybook decorator centers stories in a full height flex container and the whole thing moved while the panel resized.

yarn lint is clean and yarn test passes, 90 tests across the four Tabs suites.

Would you re-run your Repro against c2acecf? If it still bleeds for you, the thing I would want to see is what the inner TabPanels has inline, since with this version every TabPanel should carry --tab-panel-height: auto in its style attribute whether or not the browser supports registerProperty.

@snowystinger

Copy link
Copy Markdown
Member

Custom properties are inherited, so unset computes to inherit

So what does this line do? c2acecf#diff-73da442201c88cacf16dae220cbb504fba85895beea5840fdf040009060af489L281

Also, this seems contradictory? "So the control does show the bug, and the registerProperty version does stop it in Chrome. I could not reproduce what your video shows, and I still cannot explain it."
Please include your own video. Maybe I'll spot the difference in what we are doing.

I appreciate that you're trying to help, however, I am becoming more certain that AI is not up to the task of verifying this one (which is how the above comment reads) and it should tell us how much of the verification it is responsible for.

I think you will need to take more of a driver seat with this PR. It took me a few min to context switch, read the above comments, switch my branch, build this, and see that it still has the same problem.

At the moment, this is creating extra work for the team rather than reducing it. If you can verify these kinds of changes yourself before letting the AI ask for reviews, it'll help keep things moving more efficiently.

Thank you for understanding.

… on TabPanel

c2acecf reset --tab-panel-width/height on every TabPanel, which works only while
a TabPanel actually sits between an outer TabPanels and a nested one. Initialize
them on TabPanels itself instead: the element that owns and imperatively mutates
those properties is the one that declares them, so a nested TabPanels can never
inherit an outer instance's transient pixel values regardless of what renders in
between.

Also drops the registerProperty side effect, which mutated the document property
registry on import and made --tab-panel-width/height non-inheriting for consumer
CSS too, and removes the associated casts and feature detection.

Tests assert the reset rather than the branch, and fail when it is removed.
@mvanhorn

mvanhorn commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Recorded it. The short version: I still can't reproduce the bleed on the story as committed, and now I can show you why rather than just assert it.

First, your question about inherits: false.

You were right to push on it and my earlier claim was wrong. That line made the two properties non-inherited, and per CSS Cascade unset is initial for a non-inherited property and inherit for an inherited one. So on the registered path unset resolved to the registered initial value, auto, and did work. The fallback path is the only place it degenerated to inherit. My jsdom probe had no window.CSS, so supportsTabPanelSizePropertyRegistration was false and it only ever exercised that fallback — I generalised from it to the registered case, which was the overreach. "Doing literally nothing" was wrong.

Second, the measurements.

I slowed the transition from 300ms to 6s so it could be sampled, and read the computed values off both TabPanels every 500ms while clicking Large panel then Nested tabs.

c2acecf, what was on the PR until just now:

 1000ms n=2 outerVar=256px innerVar=auto innerH=37
 ...
 7000ms n=2 outerVar=256px innerVar=auto innerH=37

The branch as pushed does not bleed on this story. The inner holds auto for the whole outer transition.

To prove the harness can actually see the bug rather than just failing to find it, I stripped the reset off the nested panel as it mounts:

 1000ms n=2 outerVar=256px innerVar=256px innerH=256
 ...

That's your video — the inner inherits the outer's transient pixel value and gets pinned at 256px instead of 37px. So the setup detects the bleed when it's there; it isn't there on this story.

recording

Both states with the outer --tab-panel-height pinned to the same 256px, so the only variable is the nested panel:

comparison

Third, the structural reason, which is probably the actual answer.

On c2acecf the inner TabPanels ancestry is:

outer .animated-tabpanels
  -> [role=tabpanel] .react-aria-TabPanel   (--tab-panel-height: auto)
    -> .react-aria-Tabs
      -> inner .animated-tabpanels

The reset lands on a TabPanel that happens to sit between the two TabPanels, so it shields the inner. That only holds while a TabPanel is on that path. Two places it isn't:

  • S2's collapsed path renders Group, not RAC TabPanel (packages/@react-spectrum/s2/src/Tabs.tsx around 587-610).
  • .storybook-s2/main.ts only includes s2 and ai stories, so yarn start:s2 cannot show this story at all. If your repro came from the S2 Storybook it was necessarily a different tree.

That's my best guess at where we diverged, and it's a guess about your setup, not a claim about mine.

So I've changed the fix. 5b4fcb7 initializes the variables on TabPanels itself rather than resetting them on TabPanel: the element that owns and mutates those properties is the one that declares them, so a nested TabPanels cannot inherit an outer one regardless of what renders between. That removes the dependency on a TabPanel being on the path. Same measurements, same result, and 44 Tabs tests plus the SSR test pass; I checked they fail when the reset is removed.

What I have not done: I have not seen it fail. Every observation above is from one macOS Chrome instance driven programmatically, with the transition slowed. No Safari, no Firefox, no S2 storybook. If you can tell me which storybook and which story your recording came from, I'll go straight at that tree.

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