From 7e63cd663f7e411fa482f44cb72c6462e3771118 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 1 May 2026 11:00:35 +1000 Subject: [PATCH 1/5] [tabs] Snap indicator to device pixels --- .../src/tabs/indicator/TabsIndicator.test.tsx | 154 ++++++++++++++++-- .../src/tabs/indicator/TabsIndicator.tsx | 30 +++- .../tabs/indicator/prehydrationScript.min.ts | 2 +- .../indicator/prehydrationScript.template.js | 19 ++- 4 files changed, 179 insertions(+), 26 deletions(-) diff --git a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx index 48c1c532211..373e5d67d11 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx @@ -48,17 +48,23 @@ describe('', () => { ) { const tabRect = activeTab.getBoundingClientRect(); const tabListRect = tabList.getBoundingClientRect(); - const { width: tabWidth, height: tabHeight } = getCssDimensions(activeTab); const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabList); const scaleX = tabListWidth > 0 ? tabListRect.width / tabListWidth : 1; const scaleY = tabListHeight > 0 ? tabListRect.height / tabListHeight : 1; - - const relativeLeft = - (tabRect.left - tabListRect.left) / scaleX + tabList.scrollLeft - tabList.clientLeft; - const relativeTop = - (tabRect.top - tabListRect.top) / scaleY + tabList.scrollTop - tabList.clientTop; - const relativeRight = tabList.scrollWidth - relativeLeft - tabWidth; - const relativeBottom = tabList.scrollHeight - relativeTop - tabHeight; + const devicePixelRatio = window.devicePixelRatio || 1; + const snap = (value: number) => Math.round(value * devicePixelRatio) / devicePixelRatio; + const isVertical = tabList.getAttribute('aria-orientation') === 'vertical'; + const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); + const tabRight = isVertical ? tabRect.right : snap(tabRect.right); + const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; + const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; + + const left = (tabLeft - tabListRect.left) / scaleX + tabList.scrollLeft - tabList.clientLeft; + const top = (tabTop - tabListRect.top) / scaleY + tabList.scrollTop - tabList.clientTop; + const width = (tabRight - tabLeft) / scaleX; + const height = (tabBottom - tabTop) / scaleY; + const relativeRight = tabList.scrollWidth - left - width; + const relativeBottom = tabList.scrollHeight - top - height; const bubbleComputedStyle = window.getComputedStyle(bubble); const actualLeft = bubbleComputedStyle.getPropertyValue('--active-tab-left'); @@ -68,14 +74,138 @@ describe('', () => { const actualWidth = bubbleComputedStyle.getPropertyValue('--active-tab-width'); const actualHeight = bubbleComputedStyle.getPropertyValue('--active-tab-height'); - assertSize(actualLeft, relativeLeft); + assertSize(actualLeft, left); assertSize(actualRight, relativeRight); - assertSize(actualTop, relativeTop); + assertSize(actualTop, top); assertSize(actualBottom, relativeBottom); - assertSize(actualWidth, tabWidth); - assertSize(actualHeight, tabHeight); + assertSize(actualWidth, width); + assertSize(actualHeight, height); + } + + function getAbsoluteIndicatorEdges(bubble: HTMLElement, tabList: HTMLElement) { + const bubbleComputedStyle = window.getComputedStyle(bubble); + const left = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-left')); + const top = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-top')); + const width = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-width')); + const height = parseFloat(bubbleComputedStyle.getPropertyValue('--active-tab-height')); + const tabListRect = tabList.getBoundingClientRect(); + const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabList); + const scaleX = tabListWidth > 0 ? tabListRect.width / tabListWidth : 1; + const scaleY = tabListHeight > 0 ? tabListRect.height / tabListHeight : 1; + + return { + left: tabListRect.left + (left - tabList.scrollLeft + tabList.clientLeft) * scaleX, + right: tabListRect.left + (left + width - tabList.scrollLeft + tabList.clientLeft) * scaleX, + top: tabListRect.top + (top - tabList.scrollTop + tabList.clientTop) * scaleY, + bottom: tabListRect.top + (top + height - tabList.scrollTop + tabList.clientTop) * scaleY, + }; } + function assertDevicePixelSnapped(value: number) { + const devicePixelRatio = window.devicePixelRatio || 1; + const devicePixel = value * devicePixelRatio; + + expect(Math.abs(devicePixel - Math.round(devicePixel))).toBeLessThanOrEqual(0.01); + } + + function assertMainAxisEdgesAreSnapped(bubble: HTMLElement, tabList: HTMLElement) { + const edges = getAbsoluteIndicatorEdges(bubble, tabList); + + if (tabList.getAttribute('aria-orientation') === 'vertical') { + assertDevicePixelSnapped(edges.top); + assertDevicePixelSnapped(edges.bottom); + } else { + assertDevicePixelSnapped(edges.left); + assertDevicePixelSnapped(edges.right); + } + } + + it('snaps fractional tab edges to the device pixel grid', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + assertMainAxisEdgesAreSnapped(bubble, tabList); + }); + + it('does not introduce a cross-axis scrollbar from main-axis snapping', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + + +
, + ); + + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => + assertBubblePositionVariables(screen.getByTestId('bubble'), tabList, activeTab), + ); + + expect(tabList.scrollHeight).toBeLessThanOrEqual(tabList.clientHeight); + }); + + it('snaps fractional tab edges on the vertical axis for vertical orientation', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + + One + + + Two + + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + assertMainAxisEdgesAreSnapped(bubble, tabList); + }); + it('should set CSS variables corresponding to the active tab', async () => { await render( diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index ef8ef0393b1..cce807793e8 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { ownerWindow } from '@base-ui/utils/owner'; import { useForcedRerendering } from '@base-ui/utils/useForcedRerendering'; import { useRenderElement } from '../../internals/useRenderElement'; import { getCssDimensions } from '../../utils/getCssDimensions'; @@ -77,18 +78,33 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; if (hasNonZeroScale) { - const tabLeftDelta = tabRect.left - tabsListRect.left; - const tabTopDelta = tabRect.top - tabsListRect.top; - - left = tabLeftDelta / scaleX + tabsListElement.scrollLeft - tabsListElement.clientLeft; - top = tabTopDelta / scaleY + tabsListElement.scrollTop - tabsListElement.clientTop; + const devicePixelRatio = ownerWindow(tabsListElement).devicePixelRatio || 1; + const snap = (edge: number) => Math.round(edge * devicePixelRatio) / devicePixelRatio; + // Snap edges along the main axis only. Snapping the cross axis can push the + // indicator past the tablist's content box and trigger an unwanted scrollbar. + const isVertical = orientation === 'vertical'; + const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); + const tabRight = isVertical ? tabRect.right : snap(tabRect.right); + const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; + const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; + + left = + (tabLeft - tabsListRect.left) / scaleX + + tabsListElement.scrollLeft - + tabsListElement.clientLeft; + top = + (tabTop - tabsListRect.top) / scaleY + + tabsListElement.scrollTop - + tabsListElement.clientTop; + width = (tabRight - tabLeft) / scaleX; + height = (tabBottom - tabTop) / scaleY; } else { left = activeTab.offsetLeft; top = activeTab.offsetTop; + width = computedWidth; + height = computedHeight; } - width = computedWidth; - height = computedHeight; right = tabsListElement.scrollWidth - left - width; bottom = tabsListElement.scrollHeight - top - height; } diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts index cf5e76947bd..32561e8a562 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts +++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts @@ -2,4 +2,4 @@ // To update it, modify the corresponding source file and run `pnpm inline-scripts`. // prettier-ignore -export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i)return;if(0===i.offsetWidth||0===e.offsetWidth)return;let o=0,n=0,h=0,l=0,r=0,f=0;function s(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}if(null!=i&&null!=e){const{width:t,height:c}=s(i),{width:u,height:d}=s(e),a=i.getBoundingClientRect(),g=e.getBoundingClientRect(),p=u>0?g.width/u:1,b=d>0?g.height/d:1;if(Math.abs(p)>Number.EPSILON&&Math.abs(b)>Number.EPSILON){const t=a.left-g.left,i=a.top-g.top;o=t/p+e.scrollLeft-e.clientLeft,h=i/b+e.scrollTop-e.clientTop}else o=i.offsetLeft,h=i.offsetTop;r=t,f=c,n=e.scrollWidth-o-r,l=e.scrollHeight-h-f}function c(e,i){t.style.setProperty(`--active-tab-${e}`,`${i}px`)}c("left",o),c("right",n),c("top",h),c("bottom",l),c("width",r),c("height",f),r>0&&f>0&&t.removeAttribute("hidden")}();'; +export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i)return;if(0===i.offsetWidth||0===e.offsetWidth)return;let o=0,r=0,n=0,h=0,l=0,f=0;function s(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}if(null!=i&&null!=e){const{width:t,height:c}=s(i),{width:u,height:d}=s(e),a=i.getBoundingClientRect(),g=e.getBoundingClientRect(),p=u>0?g.width/u:1,b=d>0?g.height/d:1;if(Math.abs(p)>Number.EPSILON&&Math.abs(b)>Number.EPSILON){const t=window.devicePixelRatio||1,i=e=>Math.round(e*t)/t,r="vertical"===e.getAttribute("aria-orientation"),h=r?a.left:i(a.left),s=r?a.right:i(a.right),c=r?i(a.top):a.top,u=r?i(a.bottom):a.bottom;o=(h-g.left)/p+e.scrollLeft-e.clientLeft,n=(c-g.top)/b+e.scrollTop-e.clientTop,l=(s-h)/p,f=(u-c)/b}else o=i.offsetLeft,n=i.offsetTop,l=t,f=c;r=e.scrollWidth-o-l,h=e.scrollHeight-n-f}function c(e,i){t.style.setProperty(`--active-tab-${e}`,`${i}px`)}c("left",o),c("right",r),c("top",n),c("bottom",h),c("width",l),c("height",f),l>0&&f>0&&t.removeAttribute("hidden")}();'; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js index b24f5cc37af..74e485dc910 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.template.js +++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js @@ -54,18 +54,25 @@ const hasNonZeroScale = Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; if (hasNonZeroScale) { - const tabLeftDelta = tabRect.left - tabsListRect.left; - const tabTopDelta = tabRect.top - tabsListRect.top; + const devicePixelRatio = window.devicePixelRatio || 1; + const snap = (value) => Math.round(value * devicePixelRatio) / devicePixelRatio; + const isVertical = tabsList.getAttribute('aria-orientation') === 'vertical'; + const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); + const tabRight = isVertical ? tabRect.right : snap(tabRect.right); + const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; + const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; - left = tabLeftDelta / scaleX + tabsList.scrollLeft - tabsList.clientLeft; - top = tabTopDelta / scaleY + tabsList.scrollTop - tabsList.clientTop; + left = (tabLeft - tabsListRect.left) / scaleX + tabsList.scrollLeft - tabsList.clientLeft; + top = (tabTop - tabsListRect.top) / scaleY + tabsList.scrollTop - tabsList.clientTop; + width = (tabRight - tabLeft) / scaleX; + height = (tabBottom - tabTop) / scaleY; } else { left = activeTab.offsetLeft; top = activeTab.offsetTop; + width = computedWidth; + height = computedHeight; } - width = computedWidth; - height = computedHeight; right = tabsList.scrollWidth - left - width; bottom = tabsList.scrollHeight - top - height; } From 4a9788116577acd4d98183931e17d4b96cdb7166 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 1 May 2026 12:22:06 +1000 Subject: [PATCH 2/5] [tabs] Simplify indicator prehydration script --- .../src/tabs/indicator/TabsIndicator.tsx | 5 +- .../tabs/indicator/prehydrationScript.min.ts | 2 +- .../indicator/prehydrationScript.template.js | 76 +++++++------------ 3 files changed, 29 insertions(+), 54 deletions(-) diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index cce807793e8..ad6c8353cda 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -68,7 +68,6 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( isTabSelected = true; if (activeTab != null) { - const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); const { width: tabListWidth, height: tabListHeight } = getCssDimensions(tabsListElement); const tabRect = activeTab.getBoundingClientRect(); const tabsListRect = tabsListElement.getBoundingClientRect(); @@ -101,8 +100,8 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( } else { left = activeTab.offsetLeft; top = activeTab.offsetTop; - width = computedWidth; - height = computedHeight; + width = activeTab.offsetWidth; + height = activeTab.offsetHeight; } right = tabsListElement.scrollWidth - left - width; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts index 32561e8a562..ffd7a4a3847 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts +++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts @@ -2,4 +2,4 @@ // To update it, modify the corresponding source file and run `pnpm inline-scripts`. // prettier-ignore -export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i)return;if(0===i.offsetWidth||0===e.offsetWidth)return;let o=0,r=0,n=0,h=0,l=0,f=0;function s(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}if(null!=i&&null!=e){const{width:t,height:c}=s(i),{width:u,height:d}=s(e),a=i.getBoundingClientRect(),g=e.getBoundingClientRect(),p=u>0?g.width/u:1,b=d>0?g.height/d:1;if(Math.abs(p)>Number.EPSILON&&Math.abs(b)>Number.EPSILON){const t=window.devicePixelRatio||1,i=e=>Math.round(e*t)/t,r="vertical"===e.getAttribute("aria-orientation"),h=r?a.left:i(a.left),s=r?a.right:i(a.right),c=r?i(a.top):a.top,u=r?i(a.bottom):a.bottom;o=(h-g.left)/p+e.scrollLeft-e.clientLeft,n=(c-g.top)/b+e.scrollTop-e.clientTop,l=(s-h)/p,f=(u-c)/b}else o=i.offsetLeft,n=i.offsetTop,l=t,f=c;r=e.scrollWidth-o-l,h=e.scrollHeight-n-f}function c(e,i){t.style.setProperty(`--active-tab-${e}`,`${i}px`)}c("left",o),c("right",r),c("top",n),c("bottom",h),c("width",l),c("height",f),l>0&&f>0&&t.removeAttribute("hidden")}();'; +export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const o=e.querySelector("[data-active]");if(!o||0===o.offsetWidth||0===e.offsetWidth)return;const{width:i,height:h}=function(t){const e=getComputedStyle(t);let o=parseFloat(e.width)||0,i=parseFloat(e.height)||0;return(Math.round(o)!==t.offsetWidth||Math.round(i)!==t.offsetHeight)&&(o=t.offsetWidth,i=t.offsetHeight),{width:o,height:i}}(e),f=o.getBoundingClientRect(),n=e.getBoundingClientRect(),r=i>0?n.width/i:1,s=h>0?n.height/h:1;let l=o.offsetLeft,c=o.offsetTop,d=o.offsetWidth,u=o.offsetHeight;Math.abs(r)>Number.EPSILON&&Math.abs(s)>Number.EPSILON&&(l=(f.left-n.left)/r+e.scrollLeft-e.clientLeft,c=(f.top-n.top)/s+e.scrollTop-e.clientTop,d=f.width/r,u=f.height/s);const g={left:l,right:e.scrollWidth-l-d,top:c,bottom:e.scrollHeight-c-u,width:d,height:u};for(const e in g)t.style.setProperty(`--active-tab-${e}`,`${g[e]}px`);d>0&&u>0&&t.removeAttribute("hidden")}();'; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js index 74e485dc910..f9260e1aa78 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.template.js +++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js @@ -10,21 +10,10 @@ } const activeTab = tabsList.querySelector('[data-active]'); - if (!activeTab) { + if (!activeTab || activeTab.offsetWidth === 0 || tabsList.offsetWidth === 0) { return; } - if (activeTab.offsetWidth === 0 || tabsList.offsetWidth === 0) { - return; - } - - let left = 0; - let right = 0; - let top = 0; - let bottom = 0; - let width = 0; - let height = 0; - function getCssDimensions(element) { const css = getComputedStyle(element); let cssWidth = parseFloat(css.width) || 0; @@ -44,49 +33,36 @@ }; } - if (activeTab != null && tabsList != null) { - const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); - const { width: tabsListWidth, height: tabsListHeight } = getCssDimensions(tabsList); - const tabRect = activeTab.getBoundingClientRect(); - const tabsListRect = tabsList.getBoundingClientRect(); - const scaleX = tabsListWidth > 0 ? tabsListRect.width / tabsListWidth : 1; - const scaleY = tabsListHeight > 0 ? tabsListRect.height / tabsListHeight : 1; - const hasNonZeroScale = Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; + const { width: tabsListWidth, height: tabsListHeight } = getCssDimensions(tabsList); + const tabRect = activeTab.getBoundingClientRect(); + const tabsListRect = tabsList.getBoundingClientRect(); + const scaleX = tabsListWidth > 0 ? tabsListRect.width / tabsListWidth : 1; + const scaleY = tabsListHeight > 0 ? tabsListRect.height / tabsListHeight : 1; - if (hasNonZeroScale) { - const devicePixelRatio = window.devicePixelRatio || 1; - const snap = (value) => Math.round(value * devicePixelRatio) / devicePixelRatio; - const isVertical = tabsList.getAttribute('aria-orientation') === 'vertical'; - const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); - const tabRight = isVertical ? tabRect.right : snap(tabRect.right); - const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; - const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; + let left = activeTab.offsetLeft; + let top = activeTab.offsetTop; + let width = activeTab.offsetWidth; + let height = activeTab.offsetHeight; - left = (tabLeft - tabsListRect.left) / scaleX + tabsList.scrollLeft - tabsList.clientLeft; - top = (tabTop - tabsListRect.top) / scaleY + tabsList.scrollTop - tabsList.clientTop; - width = (tabRight - tabLeft) / scaleX; - height = (tabBottom - tabTop) / scaleY; - } else { - left = activeTab.offsetLeft; - top = activeTab.offsetTop; - width = computedWidth; - height = computedHeight; - } - - right = tabsList.scrollWidth - left - width; - bottom = tabsList.scrollHeight - top - height; + if (Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON) { + left = (tabRect.left - tabsListRect.left) / scaleX + tabsList.scrollLeft - tabsList.clientLeft; + top = (tabRect.top - tabsListRect.top) / scaleY + tabsList.scrollTop - tabsList.clientTop; + width = tabRect.width / scaleX; + height = tabRect.height / scaleY; } - function setProp(name, value) { - indicator.style.setProperty(`--active-tab-${name}`, `${value}px`); - } + const props = { + left, + right: tabsList.scrollWidth - left - width, + top, + bottom: tabsList.scrollHeight - top - height, + width, + height, + }; - setProp('left', left); - setProp('right', right); - setProp('top', top); - setProp('bottom', bottom); - setProp('width', width); - setProp('height', height); + for (const name in props) { + indicator.style.setProperty(`--active-tab-${name}`, `${props[name]}px`); + } if (width > 0 && height > 0) { indicator.removeAttribute('hidden'); From 9d40990b76dd5cb7ae253b82ea0cf591cba33a43 Mon Sep 17 00:00:00 2001 From: atomiks Date: Fri, 1 May 2026 12:46:06 +1000 Subject: [PATCH 3/5] [tabs] Fix indicator prehydration lint --- .../tabs/indicator/prehydrationScript.min.ts | 2 +- .../indicator/prehydrationScript.template.js | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts index ffd7a4a3847..9d2bb7734e2 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts +++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts @@ -2,4 +2,4 @@ // To update it, modify the corresponding source file and run `pnpm inline-scripts`. // prettier-ignore -export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const o=e.querySelector("[data-active]");if(!o||0===o.offsetWidth||0===e.offsetWidth)return;const{width:i,height:h}=function(t){const e=getComputedStyle(t);let o=parseFloat(e.width)||0,i=parseFloat(e.height)||0;return(Math.round(o)!==t.offsetWidth||Math.round(i)!==t.offsetHeight)&&(o=t.offsetWidth,i=t.offsetHeight),{width:o,height:i}}(e),f=o.getBoundingClientRect(),n=e.getBoundingClientRect(),r=i>0?n.width/i:1,s=h>0?n.height/h:1;let l=o.offsetLeft,c=o.offsetTop,d=o.offsetWidth,u=o.offsetHeight;Math.abs(r)>Number.EPSILON&&Math.abs(s)>Number.EPSILON&&(l=(f.left-n.left)/r+e.scrollLeft-e.clientLeft,c=(f.top-n.top)/s+e.scrollTop-e.clientTop,d=f.width/r,u=f.height/s);const g={left:l,right:e.scrollWidth-l-d,top:c,bottom:e.scrollHeight-c-u,width:d,height:u};for(const e in g)t.style.setProperty(`--active-tab-${e}`,`${g[e]}px`);d>0&&u>0&&t.removeAttribute("hidden")}();'; +export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const o=e.querySelector("[data-active]");if(!o||0===o.offsetWidth||0===e.offsetWidth)return;const{width:i,height:h}=function(t){const e=getComputedStyle(t);let o=parseFloat(e.width)||0,i=parseFloat(e.height)||0;return(Math.round(o)!==t.offsetWidth||Math.round(i)!==t.offsetHeight)&&(o=t.offsetWidth,i=t.offsetHeight),{width:o,height:i}}(e),f=o.getBoundingClientRect(),r=e.getBoundingClientRect(),s=i>0?r.width/i:1,n=h>0?r.height/h:1;let l=o.offsetLeft,c=o.offsetTop,d=o.offsetWidth,u=o.offsetHeight;Math.abs(s)>Number.EPSILON&&Math.abs(n)>Number.EPSILON&&(l=(f.left-r.left)/s+e.scrollLeft-e.clientLeft,c=(f.top-r.top)/n+e.scrollTop-e.clientTop,d=f.width/s,u=f.height/n),[["left",l],["right",e.scrollWidth-l-d],["top",c],["bottom",e.scrollHeight-c-u],["width",d],["height",u]].forEach(([e,o])=>{t.style.setProperty(`--active-tab-${e}`,`${o}px`)}),d>0&&u>0&&t.removeAttribute("hidden")}();'; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js index f9260e1aa78..dbed28be719 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.template.js +++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js @@ -51,18 +51,16 @@ height = tabRect.height / scaleY; } - const props = { - left, - right: tabsList.scrollWidth - left - width, - top, - bottom: tabsList.scrollHeight - top - height, - width, - height, - }; - - for (const name in props) { - indicator.style.setProperty(`--active-tab-${name}`, `${props[name]}px`); - } + [ + ['left', left], + ['right', tabsList.scrollWidth - left - width], + ['top', top], + ['bottom', tabsList.scrollHeight - top - height], + ['width', width], + ['height', height], + ].forEach(([name, value]) => { + indicator.style.setProperty(`--active-tab-${name}`, `${value}px`); + }); if (width > 0 && height > 0) { indicator.removeAttribute('hidden'); From 2ecf6af9f6e5f5abc36446ed92df30873329ac79 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 4 May 2026 13:06:55 +1000 Subject: [PATCH 4/5] [tabs] Restore indicator fallback dimensions --- packages/react/src/tabs/indicator/TabsIndicator.test.tsx | 1 + packages/react/src/tabs/indicator/TabsIndicator.tsx | 6 ++++-- packages/react/src/tabs/indicator/prehydrationScript.min.ts | 2 +- .../react/src/tabs/indicator/prehydrationScript.template.js | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx index 373e5d67d11..c8274c3e70c 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx @@ -93,6 +93,7 @@ describe('', () => { const scaleX = tabListWidth > 0 ? tabListRect.width / tabListWidth : 1; const scaleY = tabListHeight > 0 ? tabListRect.height / tabListHeight : 1; + // Convert the indicator CSS variables back to viewport coordinates. return { left: tabListRect.left + (left - tabList.scrollLeft + tabList.clientLeft) * scaleX, right: tabListRect.left + (left + width - tabList.scrollLeft + tabList.clientLeft) * scaleX, diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index ad6c8353cda..a3f51d02aae 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -98,10 +98,12 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( width = (tabRight - tabLeft) / scaleX; height = (tabBottom - tabTop) / scaleY; } else { + const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab); + left = activeTab.offsetLeft; top = activeTab.offsetTop; - width = activeTab.offsetWidth; - height = activeTab.offsetHeight; + width = computedWidth; + height = computedHeight; } right = tabsListElement.scrollWidth - left - width; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.min.ts b/packages/react/src/tabs/indicator/prehydrationScript.min.ts index 9d2bb7734e2..2685735ce69 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.min.ts +++ b/packages/react/src/tabs/indicator/prehydrationScript.min.ts @@ -2,4 +2,4 @@ // To update it, modify the corresponding source file and run `pnpm inline-scripts`. // prettier-ignore -export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const o=e.querySelector("[data-active]");if(!o||0===o.offsetWidth||0===e.offsetWidth)return;const{width:i,height:h}=function(t){const e=getComputedStyle(t);let o=parseFloat(e.width)||0,i=parseFloat(e.height)||0;return(Math.round(o)!==t.offsetWidth||Math.round(i)!==t.offsetHeight)&&(o=t.offsetWidth,i=t.offsetHeight),{width:o,height:i}}(e),f=o.getBoundingClientRect(),r=e.getBoundingClientRect(),s=i>0?r.width/i:1,n=h>0?r.height/h:1;let l=o.offsetLeft,c=o.offsetTop,d=o.offsetWidth,u=o.offsetHeight;Math.abs(s)>Number.EPSILON&&Math.abs(n)>Number.EPSILON&&(l=(f.left-r.left)/s+e.scrollLeft-e.clientLeft,c=(f.top-r.top)/n+e.scrollTop-e.clientTop,d=f.width/s,u=f.height/n),[["left",l],["right",e.scrollWidth-l-d],["top",c],["bottom",e.scrollHeight-c-u],["width",d],["height",u]].forEach(([e,o])=>{t.style.setProperty(`--active-tab-${e}`,`${o}px`)}),d>0&&u>0&&t.removeAttribute("hidden")}();'; +export const script = '!function(){const t=document.currentScript.previousElementSibling;if(!t)return;const e=t.closest(\'[role="tablist"]\');if(!e)return;const i=e.querySelector("[data-active]");if(!i||0===i.offsetWidth||0===e.offsetWidth)return;function o(t){const e=getComputedStyle(t);let i=parseFloat(e.width)||0,o=parseFloat(e.height)||0;return(Math.round(i)!==t.offsetWidth||Math.round(o)!==t.offsetHeight)&&(i=t.offsetWidth,o=t.offsetHeight),{width:i,height:o}}const{width:h,height:r}=o(e),f=i.getBoundingClientRect(),n=e.getBoundingClientRect(),l=h>0?n.width/h:1,s=r>0?n.height/r:1;let c=i.offsetLeft,d=i.offsetTop,{width:u,height:a}=o(i);Math.abs(l)>Number.EPSILON&&Math.abs(s)>Number.EPSILON&&(c=(f.left-n.left)/l+e.scrollLeft-e.clientLeft,d=(f.top-n.top)/s+e.scrollTop-e.clientTop,u=f.width/l,a=f.height/s),[["left",c],["right",e.scrollWidth-c-u],["top",d],["bottom",e.scrollHeight-d-a],["width",u],["height",a]].forEach(([e,i])=>{t.style.setProperty(`--active-tab-${e}`,`${i}px`)}),u>0&&a>0&&t.removeAttribute("hidden")}();'; diff --git a/packages/react/src/tabs/indicator/prehydrationScript.template.js b/packages/react/src/tabs/indicator/prehydrationScript.template.js index dbed28be719..dbdae82a3b2 100644 --- a/packages/react/src/tabs/indicator/prehydrationScript.template.js +++ b/packages/react/src/tabs/indicator/prehydrationScript.template.js @@ -41,10 +41,10 @@ let left = activeTab.offsetLeft; let top = activeTab.offsetTop; - let width = activeTab.offsetWidth; - let height = activeTab.offsetHeight; + let { width, height } = getCssDimensions(activeTab); if (Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON) { + // Hydration applies device-pixel snapping; this inline script stays positioning-only. left = (tabRect.left - tabsListRect.left) / scaleX + tabsList.scrollLeft - tabsList.clientLeft; top = (tabRect.top - tabsListRect.top) / scaleY + tabsList.scrollTop - tabsList.clientTop; width = tabRect.width / scaleX; From a3b41d01bf9732a7ef8817ffee74eb8fc305a319 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 4 May 2026 13:40:21 +1000 Subject: [PATCH 5/5] [tabs] Keep boundary indicator edges flush --- .../src/tabs/indicator/TabsIndicator.test.tsx | 55 +++++++++++++++++-- .../src/tabs/indicator/TabsIndicator.tsx | 16 ++++-- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx index c8274c3e70c..c582f3519b1 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx @@ -54,10 +54,22 @@ describe('', () => { const devicePixelRatio = window.devicePixelRatio || 1; const snap = (value: number) => Math.round(value * devicePixelRatio) / devicePixelRatio; const isVertical = tabList.getAttribute('aria-orientation') === 'vertical'; - const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); - const tabRight = isVertical ? tabRect.right : snap(tabRect.right); - const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; - const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; + const tabLeft = + isVertical || Math.abs(tabRect.left - tabListRect.left) < 0.01 + ? tabRect.left + : snap(tabRect.left); + const tabRight = + isVertical || Math.abs(tabRect.right - tabListRect.right) < 0.01 + ? tabRect.right + : snap(tabRect.right); + const tabTop = + !isVertical || Math.abs(tabRect.top - tabListRect.top) < 0.01 + ? tabRect.top + : snap(tabRect.top); + const tabBottom = + !isVertical || Math.abs(tabRect.bottom - tabListRect.bottom) < 0.01 + ? tabRect.bottom + : snap(tabRect.bottom); const left = (tabLeft - tabListRect.left) / scaleX + tabList.scrollLeft - tabList.clientLeft; const top = (tabTop - tabListRect.top) / scaleY + tabList.scrollTop - tabList.clientTop; @@ -127,13 +139,16 @@ describe('', () => { await render(
- + One Two + + Three + @@ -148,6 +163,33 @@ describe('', () => { assertMainAxisEdgesAreSnapped(bubble, tabList); }); + it('keeps boundary tab edges flush with the tab list edge', async () => { + const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); + + await render( +
+ + + One + Two + + + +
, + ); + + const bubble = screen.getByTestId('bubble'); + const tabList = screen.getByTestId('tab-list'); + const activeTab = screen.getAllByRole('tab')[1]; + + await waitFor(() => assertBubblePositionVariables(bubble, tabList, activeTab)); + + const edges = getAbsoluteIndicatorEdges(bubble, tabList); + expect(Math.abs(edges.right - tabList.getBoundingClientRect().right)).toBeLessThanOrEqual( + 0.01, + ); + }); + it('does not introduce a cross-axis scrollbar from main-axis snapping', async () => { const halfDevicePixel = 0.5 / (window.devicePixelRatio || 1); @@ -193,6 +235,9 @@ describe('', () => { Two + + Three + diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index a3f51d02aae..1132edaad42 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -79,13 +79,17 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( if (hasNonZeroScale) { const devicePixelRatio = ownerWindow(tabsListElement).devicePixelRatio || 1; const snap = (edge: number) => Math.round(edge * devicePixelRatio) / devicePixelRatio; - // Snap edges along the main axis only. Snapping the cross axis can push the - // indicator past the tablist's content box and trigger an unwanted scrollbar. + const isAtStart = Math.abs(tabRect.left - tabsListRect.left) < 0.01; + const isAtEnd = Math.abs(tabRect.right - tabsListRect.right) < 0.01; + const isAtTop = Math.abs(tabRect.top - tabsListRect.top) < 0.01; + const isAtBottom = Math.abs(tabRect.bottom - tabsListRect.bottom) < 0.01; + // Snap only interior edges along the main axis. Boundary edges stay flush with + // the tab list so bordered indicators can connect to an adjacent panel border. const isVertical = orientation === 'vertical'; - const tabLeft = isVertical ? tabRect.left : snap(tabRect.left); - const tabRight = isVertical ? tabRect.right : snap(tabRect.right); - const tabTop = isVertical ? snap(tabRect.top) : tabRect.top; - const tabBottom = isVertical ? snap(tabRect.bottom) : tabRect.bottom; + const tabLeft = isVertical || isAtStart ? tabRect.left : snap(tabRect.left); + const tabRight = isVertical || isAtEnd ? tabRect.right : snap(tabRect.right); + const tabTop = !isVertical || isAtTop ? tabRect.top : snap(tabRect.top); + const tabBottom = !isVertical || isAtBottom ? tabRect.bottom : snap(tabRect.bottom); left = (tabLeft - tabsListRect.left) / scaleX +