From c9f673582ab68039809046f9fe258be3b44d1d61 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 25 Jul 2026 15:11:07 +0300 Subject: [PATCH] feat(ui): glide the underline tab indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the underline tab strips the same motion as the pill ones: the indicator travels to the newly selected tab and leans toward a neighbour on press, instead of jumping. Opening, closing, and reordering tabs shift the indicator without a user switch, so those repositions still snap. Also drop the overshoot from the shared travel curve — both indicators now decelerate into place rather than springing back. --- .../src/components/ui/sortable-tabs-strip.tsx | 49 +++++++++++++------ packages/ui/src/index.css | 17 +++++-- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index bb4ac8ee..c00110bf 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -48,7 +48,7 @@ type SortableTabsStripProps = { }; // Keep in sync with `.pill-tabs__indicator--is-animated` in index.css. -const PILL_SWITCH_ANIMATION_MS = 320; +const PILL_SWITCH_ANIMATION_MS = 280; const PILL_NUDGE_PX = 4; const restrictToXAxis: Modifier = ({ transform }) => ({ @@ -124,16 +124,18 @@ export const SortableTabsStrip: React.FC = ({ const tabRefs = React.useRef>(new Map()); const [pillRect, setPillRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null); // Transitions stay off until the user actually switches tabs, so the initial - // measurement (and any container resize) repositions the pill instantly. + // measurement (and any container resize) repositions the indicator instantly. const [pillTransitionEnabled, setPillTransitionEnabled] = React.useState(false); const [pressedId, setPressedId] = React.useState(null); const pillTransitionReadyRef = React.useRef(false); const lastSwitchAtRef = React.useRef(0); + const previousActiveIdRef = React.useRef(activeId); + const previousOrderKeyRef = React.useRef(null); - // Pressing a neighbouring tab leans the pill toward it before the selection + // Pressing a neighbouring tab leans the indicator toward it before the selection // commits, which is what makes the control feel physical rather than snappy. const pillNudge = React.useMemo(() => { - if (!usesActivePillIndicator || !pressedId || !activeId || pressedId === activeId) { + if (!usesIndicator || !pressedId || !activeId || pressedId === activeId) { return 0; } const pressedIndex = items.findIndex((item) => item.id === pressedId); @@ -142,7 +144,7 @@ export const SortableTabsStrip: React.FC = ({ return 0; } return pressedIndex > activeIndex ? PILL_NUDGE_PX : -PILL_NUDGE_PX; - }, [activeId, items, pressedId, usesActivePillIndicator]); + }, [activeId, items, pressedId, usesIndicator]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -284,6 +286,8 @@ export const SortableTabsStrip: React.FC = ({ updateActivePillRect(); }); + const itemOrderKey = itemIDs.join(''); + React.useLayoutEffect(() => { if (!usesIndicator) { pillTransitionReadyRef.current = false; @@ -291,16 +295,30 @@ export const SortableTabsStrip: React.FC = ({ return; } - // The first pass only records that a pill exists; animating it would slide - // the pill in from the track origin on mount. + const orderChanged = previousOrderKeyRef.current !== itemOrderKey; + previousOrderKeyRef.current = itemOrderKey; + + // The first pass only records that an indicator exists; animating it would + // slide it in from the track origin on mount. if (!pillTransitionReadyRef.current) { pillTransitionReadyRef.current = true; + previousActiveIdRef.current = activeId; + return; + } + + const switched = previousActiveIdRef.current !== activeId; + previousActiveIdRef.current = activeId; + + // Opening, closing, or reordering tabs shifts the indicator without the user + // switching tabs; that repositioning should snap rather than glide. + if (!switched || orderChanged) { + setPillTransitionEnabled(false); return; } lastSwitchAtRef.current = performance.now(); setPillTransitionEnabled(true); - }, [activeId, usesIndicator]); + }, [activeId, itemOrderKey, usesIndicator]); React.useEffect(() => { if (!isScrollable || !activeId) { @@ -402,9 +420,12 @@ export const SortableTabsStrip: React.FC = ({ ) : null} {useUnderlineIndicator && pillRect ? (
= ({ aria-selected={isActive} aria-label={showInactiveIconOnly ? (item.title ?? item.label) : undefined} onClick={() => onSelect(item.id)} - onPointerDown={usesActivePillIndicator ? () => { + onPointerDown={usesIndicator ? () => { setPillTransitionEnabled(true); setPressedId(item.id); } : undefined} - onPointerUp={usesActivePillIndicator ? () => setPressedId(null) : undefined} - onPointerLeave={usesActivePillIndicator ? () => setPressedId(null) : undefined} - onPointerCancel={usesActivePillIndicator ? () => setPressedId(null) : undefined} + onPointerUp={usesIndicator ? () => setPressedId(null) : undefined} + onPointerLeave={usesIndicator ? () => setPressedId(null) : undefined} + onPointerCancel={usesIndicator ? () => setPressedId(null) : undefined} className={cn( usesActivePillIndicator ? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 min-w-0 flex-nowrap items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0' diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 63ed7386..d55fa2ab 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -939,14 +939,22 @@ html:not(.dark) .chat-scroll { * instead of sliding in from the track origin. */ .pill-tabs__indicator--is-animated { - /* Slight overshoot on travel reads as a spring; size stays non-overshooting - so wide/narrow label swaps do not wobble. */ + /* Decelerating ease with no overshoot: the indicator settles rather than + bouncing back at the end of its travel. */ transition: - transform 320ms cubic-bezier(0.34, 1.3, 0.64, 1), + transform 280ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1); } +/* Underline indicator shares the pill's travel curve so both tab styles feel + like the same control. */ +.underline-tabs__indicator--is-animated { + transition: + transform 280ms cubic-bezier(0.22, 1, 0.36, 1), + width 260ms cubic-bezier(0.22, 1, 0.36, 1); +} + .pill-tabs__button { transition-property: color, transform; transition-duration: 150ms; @@ -958,7 +966,8 @@ html:not(.dark) .chat-scroll { } @media (prefers-reduced-motion: reduce) { - .pill-tabs__indicator--is-animated { + .pill-tabs__indicator--is-animated, + .underline-tabs__indicator--is-animated { transition: none; }