Skip to content

[Tabs] Consider 3D transforms when positioning the indicator#4852

Open
michaldudak wants to merge 13 commits into
mui:masterfrom
michaldudak:tabs-indicator-3d
Open

[Tabs] Consider 3D transforms when positioning the indicator#4852
michaldudak wants to merge 13 commits into
mui:masterfrom
michaldudak:tabs-indicator-3d

Conversation

@michaldudak
Copy link
Copy Markdown
Member

@michaldudak michaldudak commented May 19, 2026

Fixes Tabs.Indicator desynchronizing when the tab list is inside a 3D-transformed ancestor.

The previous implementation derived indicator coordinates from getBoundingClientRect() deltas and an inferred scale factor. That works for simple translations and axis-aligned scale transforms, but breaks with rotations, skew, perspective, or 3D transforms because bounding rects are projected viewport geometry and cannot always be mapped back to tab-list coordinates with a single scale.

This keeps the rect-based path for transforms it can represent accurately, and falls back to layout offsets when the active tab or one of its ancestors has a distorting transform. The fallback walks the offsetParent chain for both the tab and tab list and subtracts their layout positions, so the indicator stays aligned because it lives in the same transformed subtree as the tab.

The same logic is applied to the prehydration script, and browser regression tests cover rotated and 3D-transformed ancestors.

I'm deliberately not supporting shadow DOM to keep bundle size in check. Transformed tabs alone are very rare. Transformed tabs using shadow DOM even more so.

Fixes #4837

Demo: https://deploy-preview-4852--base-ui.netlify.app/experiments/tabs/tabs-3d-transform

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 19, 2026

commit: 46e98cf

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 19, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+1.39KB(+0.30%) 🔺+530B(+0.36%)

Details of bundle changes

Performance

Total duration: 1,149.21 ms -174.46 ms(-13.2%) | Renders: 50 (+0) | Paint: 1,743.59 ms -268.79 ms(-13.4%)

Test Duration Renders
Menu mount (300 instances) 129.26 ms ▼-41.28 ms(-24.2%) 2 (+0)
Select mount (200 instances) 138.16 ms ▼-36.18 ms(-20.8%) 3 (+0)
Scroll Area mount (300 instances) 83.86 ms ▼-23.81 ms(-22.1%) 3 (+0)
Select open (500 options) 42.07 ms ▼-15.49 ms(-26.9%) 14 (+0)

8 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 19, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 46e98cf
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a0eb6e36bba1400081318d4
😎 Deploy Preview https://deploy-preview-4852--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@michaldudak michaldudak changed the title [Tabs] Consider 3d transforms when positioning the indicator [Tabs] Consider 3D transforms when positioning the indicator May 19, 2026
@michaldudak michaldudak added component: tabs Changes related to the tabs component. type: bug It doesn't behave as expected. labels May 19, 2026
@michaldudak michaldudak marked this pull request as ready for review May 19, 2026 08:23
Copy link
Copy Markdown
Member

@flaviendelangle flaviendelangle left a comment

Choose a reason for hiding this comment

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

PR #4852 Review Summary — [Tabs] Consider 3D transforms when positioning the indicator

Branch: tabs-indicator-3d · Author: @michaldudak · Diff: +132 / −46 across 4 files

Critical Issues (1)

  • pr-test-analyzer: Tautological test — TabsIndicator.test.tsx:44-66 re-implements getElementOffset / getRelativeLayoutOffset byte-for-byte identical to the production code at TabsIndicator.tsx:23-45. A formula regression would pass. Worse: the previous helper used getBoundingClientRect + scale division, which was an independent oracle — that independence is now lost for every existing case routed through assertBubblePositionVariables (lines 94, 116, 144, 175, 222, 271).

Important Issues (3)

  • silent-failure-hunter: offsetParent === null is handled silently — TabsIndicator.tsx:23-34. For position: fixed tab lists with non-zero size, the existing width/height guards do not catch it; the function returns (0, 0) and the indicator anchors to viewport origin. The deleted hasNonZeroScale else-branch and the old getBoundingClientRect()-returns-zero path used to absorb this. Consider returning null and bailing in the caller, or a dev-mode assertion.
  • pr-test-analyzer: Prehydration script has zero behavioral coverage — the only test (TabsIndicator.test.tsx:462-473) verifies the <script> tag exists but never executes it. The duplicated math in prehydrationScript.template.js and .min.ts can drift from TabsIndicator.tsx undetected.
  • silent-failure-hunter: Prehydration loop has no type guard — prehydrationScript.template.js:47-56. A non-HTMLElement offsetParent (e.g., SVG root) yields undefined arithmetic → NaN CSS variables. The TSX-side as HTMLElement | null cast at TabsIndicator.tsx:25 masks rather than validates this.

Suggestions (4)

  • pr-test-analyzer: Harden the 3D test (TabsIndicator.test.tsx:222-268) with fixture-derived literal expectations, e.g. expectedLeft = 2*120 + 2*8 = 256. The 3D ancestor proves getBoundingClientRect() math would be wrong; offset-walk should still return the literal CSS-box value — that's the assertion that catches drift.
  • pr-test-analyzer: Add coverage for orientation="vertical", RTL, an intermediate position: relative wrapper between tab and tab list, and a tab list that is itself the transformed element (transform makes it its descendants' offsetParent).
  • silent-failure-hunter: Add a scaleX(0) regression test — the deleted hasNonZeroScale branch was reachable. The new offset-walk handles it correctly, but only the rotateY case is exercised.
  • comment-analyzer: One-line comment justified on - ancestor.clientLeft / - ancestor.clientTop at TabsIndicator.tsx:42-44 (and mirrors in template + test). The loop adds the ancestor's own clientLeft once, so the final subtraction is the non-obvious "remove ancestor's own border" step.

Strengths

  • code-reviewer (full trace): Algorithm is mathematically correct across all traced cases — direct child, non-positioned wrapper, positioned wrapper with borders, no-offsetParent root. Common-ancestor contributions cancel exactly in the subtraction.
  • code-reviewer: Dropping tabList.scrollLeft/scrollTop is correct — offsetLeft is unaffected by ancestor scrolling (unlike getBoundingClientRect), so the old compensation is no longer needed.
  • code-reviewer: Removing the hasNonZeroScale fallback is a net improvement — the old fallback to raw activeTab.offsetLeft was buggy for non-direct-child tabs anyway.
  • comment-analyzer: No stale comments left behind; no superfluous new ones added — consistent with project's default-no-comments stance.
  • Bonus: minified prehydration script is smaller.

Recommended Action

  1. Address the tautology — replace the test's recomputed helper with literal pixel expectations derived from the CSS box model. This is the highest-leverage fix because it strengthens every case in the file, not just the new one.
  2. Decide on the offsetParent === null policy — either an early-bail (preferred) or a dev-mode assertion. Quick, low-risk.
  3. Add a scaleX(0) test alongside the 3D one to close the gap left by removing hasNonZeroScale.
  4. Optional: one-line comment on the ancestor-clientLeft subtraction and an instanceof HTMLElement guard in the prehydration loop.

The core change is sound and fixes a real bug (#4837). The blocking concern is test independence — fix that and the PR is in good shape.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented May 19, 2026

Sizing is less ideal:

Before:
Screenshot 2026-05-19 at 10 08 25 pm

After:
Screenshot 2026-05-19 at 10 08 38 pm

https://master--base-ui.netlify.app/experiments/tabs/tabs-overflow

@michaldudak michaldudak requested a review from aarongarciah as a code owner May 20, 2026 10:29
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db87dce5f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/tabs/indicator/TabsIndicator.tsx Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0972d5dd1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +106 to +109
return hasTransform(element) ||
(Math.abs(rectOffset[0] - layoutOffset[0]) <= 1 &&
Math.abs(rectOffset[1] - layoutOffset[1]) <= 1)
? rectOffset
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use layout fallback for transformed tabs under 3D skew

getIndicatorOffset unconditionally returns rectOffset whenever the active tab has any transform, which reintroduces the projection bug this change is trying to avoid when the tab list is inside a 3D-transformed ancestor. In that mixed case (e.g., a transformed active tab for visual state + parent rotateY(...)), getBoundingClientRect() deltas are skewed, but this branch bypasses layoutOffset even when the mismatch is large, so the indicator can visibly drift from the tab; the same logic is mirrored in the prehydration script, so SSR/hydration can jump too.

Useful? React with 👍 / 👎.

@michaldudak michaldudak marked this pull request as draft May 20, 2026 11:47
@michaldudak michaldudak marked this pull request as ready for review May 21, 2026 07:02
Copy link
Copy Markdown
Member Author

Codex Review (GPT-5.5)

Reviewed the full PR 4852 branch against freshly fetched upstream/master. Dominant type: bug-fix; the offset-path approach looks sound for the reported 3D transform bug, with one remaining browser-facing transform gap.

1. Bugs / Issues

1. 🟡 CSS scale longhand flips still use the rect path (non-blocking)

packages/react/src/tabs/indicator/hasDistortingTransform.ts

function hasDistortingTransformLonghand(css: CSSStyleDeclaration): boolean {
  const rotate = css.getPropertyValue('rotate').trim();
  // ...
  return (
    // rotate / perspective only
  );
}

The helper handles rotate longhand, but not scale. In Chromium, scale: -1 1 leaves computedStyle.transform as none, so the matrix check is bypassed; the rect formula then loses the flip direction because rect.width / cssWidth is positive.

Fix: Parse css.getPropertyValue('scale') and treat negative X/Y scale values as distorting. Add a browser regression for style={{ scale: '-1 1' }} and mirror the logic in the prehydration script.

Test Coverage Assessment

I ran pnpm test:chromium TabsIndicator --no-watch and pnpm test:jsdom TabsIndicator --no-watch; both passed. The added browser tests cover the reported 3D transform case plus rotation and shorthand flip cases.

Recommendation

Approve after nits are addressed 🟡

The main fix addresses the reported desync and is covered by targeted browser tests. I’d address the longhand scale gap before merging so the transform classifier covers the same flip semantics regardless of whether authors use shorthand or individual transform properties.

@michaldudak
Copy link
Copy Markdown
Member Author

I'm going to ignore the scale issue, not to introduce code that handles a very exotic case

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46e98cff03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (hasNonZeroScale) {
// A rotation, skew, flip, or 3D transform on the tab or any ancestor warps the
// bounding rects, so the rect-based fast path can't recover the tab's position.
const useOffsetPath = !hasNonZeroScale || hasDistortingTransform(activeTab);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit offset fallback to transforms that also affect the indicator

The new useOffsetPath gate switches to layout offsets whenever hasDistortingTransform(activeTab) is true, even when the distorting transform is on an ancestor that wraps only the active tab branch (not Tabs.Indicator). In that layout, getLayoutOffset (lines 86-88) ignores the tab’s visual transform, so the indicator is placed at the untransformed position and drifts from the rendered tab; previously the rect path tracked that transformed position. Fresh evidence: line 82 now unconditionally routes these cases to the offset path based on the tab’s ancestor chain, without checking whether the transform is shared with the indicator subtree.

Useful? React with 👍 / 👎.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented May 21, 2026

The bundle size increase is unfortunate. The fix handles a real edge case, but it also adds a lot of complexity

Given how uncommon advanced transformed tab layouts are, I’d lean toward keeping the built-in indicator simpler and documenting this as a limitation. For users building these kinds of complex effects, it may be better to use a custom indicator implementation, or CSS anchor positioning if browser support works well enough with transforms

@michaldudak
Copy link
Copy Markdown
Member Author

We could support just CSS transform (without longhand rotate) for a ~200B savings in bundle size.
I was also considering a new part, but I don't think it's worth it. It may be pretty confusing to users.

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

Labels

component: tabs Changes related to the tabs component. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[tabs] Tabs.Indicator position gets stuck / desynchronized inside a 3D rotated parent

3 participants