From aab9f3bca3a9504f278c76ba71291d8bb9556ee0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 25 Jul 2026 15:05:28 +0300 Subject: [PATCH] feat(ui): spring the active pill in tab strips Give the active-pill tab strips the lifted, physical feel of a spring capsule: the pill now travels with a slight overshoot instead of jumping, leans toward a neighbouring tab on press, and sits on a raised surface with a soft ambient shadow rather than a hard border. Transitions are opt-in per switch so first measurement and container resizes still snap into place, and reduced-motion disables them. Also drop the icons from the git commit/update/pr switcher, along with the now-unused iconOnlyActiveTab prop. --- .../src/components/ui/sortable-tabs-strip.tsx | 77 ++++++++++++++++--- packages/ui/src/components/views/GitView.tsx | 6 +- .../ui/src/components/views/git/GitHeader.tsx | 1 - packages/ui/src/index.css | 34 ++++++++ 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index 52201ade..bb4ac8ee 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -42,12 +42,15 @@ type SortableTabsStripProps = { activePillInsetClassName?: string; activePillButtonClassName?: string; inactiveTabsIconOnly?: boolean; - iconOnlyActiveTab?: boolean; animateActivePill?: boolean; activePillLowercase?: boolean; className?: string; }; +// Keep in sync with `.pill-tabs__indicator--is-animated` in index.css. +const PILL_SWITCH_ANIMATION_MS = 320; +const PILL_NUDGE_PX = 4; + const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0, @@ -95,7 +98,6 @@ export const SortableTabsStrip: React.FC = ({ activePillInsetClassName, activePillButtonClassName, inactiveTabsIconOnly = false, - iconOnlyActiveTab = false, animateActivePill, activePillLowercase = true, className, @@ -116,11 +118,31 @@ export const SortableTabsStrip: React.FC = ({ const usesIndicator = usesActivePillIndicator || useUnderlineIndicator; const useIntrinsicPillSizing = isActivePillVariant && isScrollable; const showPillTrackBackground = usesActivePillIndicator; - const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant; + const shouldAnimateActivePill = animateActivePill ?? usesActivePillIndicator; const reorderEnabled = typeof onReorder === 'function'; const Wrapper = reorderEnabled ? SortableTabWrapper : StaticTabWrapper; 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. + const [pillTransitionEnabled, setPillTransitionEnabled] = React.useState(false); + const [pressedId, setPressedId] = React.useState(null); + const pillTransitionReadyRef = React.useRef(false); + const lastSwitchAtRef = React.useRef(0); + + // Pressing a neighbouring tab leans the pill 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) { + return 0; + } + const pressedIndex = items.findIndex((item) => item.id === pressedId); + const activeIndex = items.findIndex((item) => item.id === activeId); + if (pressedIndex < 0 || activeIndex < 0) { + return 0; + } + return pressedIndex > activeIndex ? PILL_NUDGE_PX : -PILL_NUDGE_PX; + }, [activeId, items, pressedId, usesActivePillIndicator]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -236,7 +258,14 @@ export const SortableTabsStrip: React.FC = ({ return; } - const observer = new ResizeObserver(updateActivePillRect); + const observer = new ResizeObserver(() => { + // Layout-driven repositioning should snap, not slide — except while a tab + // switch is still animating, where the active tab may legitimately resize. + if (performance.now() - lastSwitchAtRef.current > PILL_SWITCH_ANIMATION_MS) { + setPillTransitionEnabled(false); + } + updateActivePillRect(); + }); observer.observe(element); if (activeId) { @@ -255,6 +284,24 @@ export const SortableTabsStrip: React.FC = ({ updateActivePillRect(); }); + React.useLayoutEffect(() => { + if (!usesIndicator) { + pillTransitionReadyRef.current = false; + setPillTransitionEnabled(false); + return; + } + + // The first pass only records that a pill exists; animating it would slide + // the pill in from the track origin on mount. + if (!pillTransitionReadyRef.current) { + pillTransitionReadyRef.current = true; + return; + } + + lastSwitchAtRef.current = performance.now(); + setPillTransitionEnabled(true); + }, [activeId, usesIndicator]); + React.useEffect(() => { if (!isScrollable || !activeId) { return; @@ -327,7 +374,7 @@ export const SortableTabsStrip: React.FC = ({ usesActivePillIndicator && 'pill-tabs__track', usesActivePillIndicator && (activePillInsetClassName ?? 'gap-0.5 py-0.5'), useUnderlineIndicator && 'items-center overflow-y-hidden', - showPillTrackBackground && 'rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[color-mix(in_srgb,var(--foreground)_2%,transparent)] p-0.5 gap-0.5', + showPillTrackBackground && 'rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[color-mix(in_srgb,var(--foreground)_4%,transparent)] p-0.5 gap-0.5', isScrollable ? 'overflow-x-auto scrollbar-none' : 'overflow-x-hidden', @@ -340,15 +387,16 @@ export const SortableTabsStrip: React.FC = ({
) : null} @@ -366,7 +414,7 @@ export const SortableTabsStrip: React.FC = ({ const isActive = item.id === activeId; const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon); const shouldShowLabel = !showInactiveIconOnly; - const shouldShowIcon = Boolean(item.icon) && (!iconOnlyActiveTab || isActive); + const shouldShowIcon = Boolean(item.icon); const useIntrinsicActiveTab = inactiveTabsIconOnly && usesActivePillIndicator && isActive && !isScrollable && !useIntrinsicPillSizing; const closable = item.closable !== false && Boolean(onClose); const closeReplacesIcon = closable && Boolean(item.icon); @@ -422,6 +470,13 @@ export const SortableTabsStrip: React.FC = ({ aria-selected={isActive} aria-label={showInactiveIconOnly ? (item.title ?? item.label) : undefined} onClick={() => onSelect(item.id)} + onPointerDown={usesActivePillIndicator ? () => { + setPillTransitionEnabled(true); + setPressedId(item.id); + } : undefined} + onPointerUp={usesActivePillIndicator ? () => setPressedId(null) : undefined} + onPointerLeave={usesActivePillIndicator ? () => setPressedId(null) : undefined} + onPointerCancel={usesActivePillIndicator ? () => 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/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index ab0afb92..74b6ef3d 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -618,9 +618,9 @@ export const GitView: React.FC = ({ isActive }) => { const [gitLogDialogMode, setGitLogDialogMode] = React.useState(null); const actionTabItems = React.useMemo(() => [ - { id: 'commit', label: t('gitView.tabs.commit'), icon: }, - { id: 'branch', label: t('gitView.tabs.update'), icon: }, - { id: 'pr', label: t('gitView.tabs.pr'), icon: }, + { id: 'commit', label: t('gitView.tabs.commit') }, + { id: 'branch', label: t('gitView.tabs.update') }, + { id: 'pr', label: t('gitView.tabs.pr') }, ], [t]); const [actionTab, setActionTab] = React.useState(() => { if (typeof window === 'undefined') { diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index bb255a5b..c38b746c 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -374,7 +374,6 @@ export const GitHeader: React.FC = ({ onSelect={onSelectActionTab} layoutMode="fit" variant="active-pill" - iconOnlyActiveTab={true} activePillButtonClassName="h-7" className="h-full" /> diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index f7bafb2b..63ed7386 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -933,6 +933,40 @@ html:not(.dark) .chat-scroll { } } +/* + * Active pill indicator motion. Transitions are opt-in via the --is-animated + * modifier so first measurement and container resizes reposition instantly + * 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. */ + transition: + transform 320ms cubic-bezier(0.34, 1.3, 0.64, 1), + width 260ms cubic-bezier(0.22, 1, 0.36, 1), + height 260ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.pill-tabs__button { + transition-property: color, transform; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +.pill-tabs__button:active { + transform: scale(0.97); +} + +@media (prefers-reduced-motion: reduce) { + .pill-tabs__indicator--is-animated { + transition: none; + } + + .pill-tabs__button:active { + transform: none; + } +} + /* Text font */ .markdown-content { font-family: var(--font-sans);