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.
This commit is contained in:
Bohdan Triapitsyn
2026-07-25 15:05:28 +03:00
parent 248e403977
commit aab9f3bca3
4 changed files with 103 additions and 15 deletions
@@ -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<SortableTabsStripProps> = ({
activePillInsetClassName,
activePillButtonClassName,
inactiveTabsIconOnly = false,
iconOnlyActiveTab = false,
animateActivePill,
activePillLowercase = true,
className,
@@ -116,11 +118,31 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
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<Map<string, HTMLElement>>(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<string | null>(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<SortableTabsStripProps> = ({
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<SortableTabsStripProps> = ({
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<SortableTabsStripProps> = ({
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<SortableTabsStripProps> = ({
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-0 rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[var(--surface-elevated)]',
'border border-border/60'
// Lifted card look: hairline edge plus a soft ambient shadow rather
// than a hard border, so the pill reads as raised above the track.
'border border-[color-mix(in_srgb,var(--foreground)_7%,transparent)]',
'shadow-[0_1px_2px_color-mix(in_srgb,var(--foreground)_10%,transparent),0_2px_6px_color-mix(in_srgb,var(--foreground)_6%,transparent)]',
shouldAnimateActivePill && pillTransitionEnabled && 'pill-tabs__indicator--is-animated'
)}
style={{
transform: `translate3d(${pillRect.left}px, ${pillRect.top}px, 0)`,
transform: `translate3d(${pillRect.left + pillNudge}px, ${pillRect.top}px, 0)`,
width: `${pillRect.width}px`,
height: `${pillRect.height}px`,
transition: shouldAnimateActivePill
? 'transform 300ms cubic-bezier(0.65, 0, 0.35, 1), width 300ms cubic-bezier(0.65, 0, 0.35, 1), height 300ms cubic-bezier(0.65, 0, 0.35, 1)'
: undefined,
}}
/>
) : null}
@@ -366,7 +414,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
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<SortableTabsStripProps> = ({
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'
+3 -3
View File
@@ -618,9 +618,9 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
const actionTabItems = React.useMemo(() => [
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <Icon name="git-commit" className="h-3.5 w-3.5" /> },
{ id: 'branch', label: t('gitView.tabs.update'), icon: <Icon name="git-merge" className="h-3.5 w-3.5" /> },
{ id: 'pr', label: t('gitView.tabs.pr'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
{ 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<ActionTab>(() => {
if (typeof window === 'undefined') {
@@ -374,7 +374,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelect={onSelectActionTab}
layoutMode="fit"
variant="active-pill"
iconOnlyActiveTab={true}
activePillButtonClassName="h-7"
className="h-full"
/>
+34
View File
@@ -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);