Skip to content

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

Draft
michaldudak wants to merge 8 commits into
mui:masterfrom
michaldudak:tabs-indicator-3d
Draft

[Tabs] Consider 3D transforms when positioning the indicator#4852
michaldudak wants to merge 8 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.

Fixes #4837

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 19, 2026

commit: d08e154

@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 🔺+504B(+0.11%) 🔺+201B(+0.14%)

Details of bundle changes

Performance

Total duration: 1,154.97 ms +1.80 ms(+0.2%) | Renders: 50 (+0) | Paint: 1,770.45 ms -10.99 ms(-0.6%)

Test Duration Renders
Menu mount (300 instances) 147.45 ms 🔺+31.76 ms(+27.4%) 2 (+0)
Select mount (200 instances) 118.25 ms ▼-30.95 ms(-20.7%) 3 (+0)
Mixed surface mount (app-like density) 60.37 ms ▼-20.60 ms(-25.4%) 5 (+0)

9 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 d08e154
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a0df2029880980008046994
😎 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
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