From 1ba3417ed79a2db6a649724a6007644037a01b25 Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Thu, 13 Aug 2026 13:41:07 -0400 Subject: [PATCH] fix(ui): stop Tabs from wrapping and overflowing its track on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tabs track rendered as `inline-flex` with no width floor, so inside a horizontally scrollable parent it shrank to the viewport instead of overflowing it. On a 390px screen the B20 demo's tab labels wrapped to multiple lines and the active pill — measured from the button's box — spilled past the rounded track's right edge. - `w-max` on the track so it takes its natural width and the `overflow-x-auto` parent scrolls instead of compressing it - `shrink-0 whitespace-nowrap` on each tab to keep labels on one line - scroll the selected tab into view in `measure()`, since a scrollable row can otherwise leave the active tab off-screen with no indication. Done by hand rather than via `scrollIntoView`, which would also scroll the page vertically. Verified at 390x844 on /demos/b20. Checked /upgrades, /snapshots, and /demos/account at the same width — their tab sets never overflow, so the change is inert there. Generated with Claude Code Co-Authored-By: Claude --- app/components/ui/Tabs.tsx | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/app/components/ui/Tabs.tsx b/app/components/ui/Tabs.tsx index ea09831..9242953 100644 --- a/app/components/ui/Tabs.tsx +++ b/app/components/ui/Tabs.tsx @@ -24,6 +24,21 @@ type TabsProps = { const PILL_TRANSITION = { type: 'spring', bounce: 0, duration: 0.3 } as const; +// Breathing room left between a scrolled-into-view tab and the scroller edge. +const SCROLL_MARGIN = 8; + +function findScrollParent(el: HTMLElement): HTMLElement | null { + let node = el.parentElement; + while (node) { + if (node.scrollWidth > node.clientWidth) { + const overflowX = getComputedStyle(node).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return node; + } + node = node.parentElement; + } + return null; +} + export function Tabs({ items, value, @@ -47,6 +62,17 @@ export function Tabs({ const cr = container.getBoundingClientRect(); const br = btn.getBoundingClientRect(); setPill({ x: br.left - cr.left, width: br.width }); + + // On narrow screens the tab row is wider than its scroll container, so the + // selected tab can sit off-screen. Nudge it into view horizontally only — + // scrollIntoView would also move the page vertically. + const scroller = findScrollParent(container); + if (!scroller) return; + const sr = scroller.getBoundingClientRect(); + const overflowLeft = sr.left - br.left; + const overflowRight = br.right - sr.right; + if (overflowLeft > 0) scroller.scrollLeft -= overflowLeft + SCROLL_MARGIN; + else if (overflowRight > 0) scroller.scrollLeft += overflowRight + SCROLL_MARGIN; }, [value]); useEffect(() => { @@ -66,7 +92,7 @@ export function Tabs({ ref={containerRef} role="tablist" aria-label={ariaLabel} - className={cn('relative inline-flex rounded-full bg-bds-gray-5 p-1', className)} + className={cn('relative inline-flex w-max rounded-full bg-bds-gray-5 p-1', className)} > {pill && (