diff --git a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx
index 48c1c532211..c582f3519b1 100644
--- a/packages/react/src/tabs/indicator/TabsIndicator.test.tsx
+++ b/packages/react/src/tabs/indicator/TabsIndicator.test.tsx
@@ -48,17 +48,35 @@ 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 || 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;
+ 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 +86,172 @@ 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;
+
+ // 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,
+ 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
+
+
+ Three
+
+
+
+
+
,
+ );
+
+ 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('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);
+
+ 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
+
+
+ Three
+
+
+
+
+
,
+ );
+
+ 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..1132edaad42 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';
@@ -67,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();
@@ -77,18 +77,39 @@ 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;
+ 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 || 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 +
+ tabsListElement.scrollLeft -
+ tabsListElement.clientLeft;
+ top =
+ (tabTop - tabsListRect.top) / scaleY +
+ tabsListElement.scrollTop -
+ tabsListElement.clientTop;
+ width = (tabRight - tabLeft) / scaleX;
+ height = (tabBottom - tabTop) / scaleY;
} else {
+ const { width: computedWidth, height: computedHeight } = getCssDimensions(activeTab);
+
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..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 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||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 b24f5cc37af..dbdae82a3b2 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,42 +33,34 @@
};
}
- 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 tabLeftDelta = tabRect.left - tabsListRect.left;
- const tabTopDelta = tabRect.top - tabsListRect.top;
+ let left = activeTab.offsetLeft;
+ let top = activeTab.offsetTop;
+ let { width, height } = getCssDimensions(activeTab);
- left = tabLeftDelta / scaleX + tabsList.scrollLeft - tabsList.clientLeft;
- top = tabTopDelta / scaleY + tabsList.scrollTop - tabsList.clientTop;
- } 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) {
+ // 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;
+ height = tabRect.height / scaleY;
}
- function setProp(name, value) {
+ [
+ ['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`);
- }
-
- setProp('left', left);
- setProp('right', right);
- setProp('top', top);
- setProp('bottom', bottom);
- setProp('width', width);
- setProp('height', height);
+ });
if (width > 0 && height > 0) {
indicator.removeAttribute('hidden');