Improve mobile swipe tabs with native scroll-snap

- Rewrite SwipeTabs to use CSS scroll-snap for smoother transitions
- Add GPU acceleration hints for fluid animations
- Use native scrolling behavior instead of manual touch handling
- Add swipe-tabs--snap and swipe-tabs--static variants
- Improve height transitions and layout containment
- Update dimension variables for better spacing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 17:08:50 +01:00
parent fc605f03c9
commit abd8f75efc
10 changed files with 552 additions and 112 deletions

View File

@@ -0,0 +1,326 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
type SwipeTabsProps<T extends string> = {
tabs: readonly T[];
activeTab: T;
onTabChange: (tab: T) => void;
renderPanel: (tab: T) => ReactNode;
className?: string;
panelClassName?: string;
renderWindow?: number;
threshold?: number;
directionRatio?: number;
respectScrollableTargets?: boolean;
};
const SCROLL_END_DELAY = 120;
const COARSE_POINTER_QUERY = '(pointer: coarse)';
const NO_HOVER_QUERY = '(hover: none)';
const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)';
const getSwipeEnabled = () => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches;
const noHover = window.matchMedia(NO_HOVER_QUERY).matches;
if (coarsePointer || noHover) return true;
const supportsTouch = ('ontouchstart' in window)
|| (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
if (!supportsTouch) return false;
return window.matchMedia(FALLBACK_WIDTH_QUERY).matches;
};
export function SwipeTabs<T extends string>({
tabs,
activeTab,
onTabChange,
renderPanel,
className,
panelClassName,
renderWindow = 0,
}: SwipeTabsProps<T>) {
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef(new Map<T, HTMLDivElement>());
const activeIndex = tabs.indexOf(activeTab);
const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0;
const maxIndex = Math.max(0, tabs.length - 1);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const [isScrolling, setIsScrolling] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0);
const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled());
const scrollEndTimer = useRef<number | null>(null);
const scrollRaf = useRef<number | null>(null);
const scrollPositionRef = useRef(0);
const isUserScrolling = useRef(false);
const hasMounted = useRef(false);
const lastReportedIndex = useRef(safeActiveIndex);
const prefersReducedMotion = useMemo(() => {
if (typeof window === 'undefined' || !window.matchMedia) return true;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}, []);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const pointerMql = window.matchMedia(COARSE_POINTER_QUERY);
const hoverMql = window.matchMedia(NO_HOVER_QUERY);
const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY);
const update = () => setSwipeEnabled(getSwipeEnabled());
update();
const addListener = (mql: MediaQueryList) => {
if (mql.addEventListener) {
mql.addEventListener('change', update);
return;
}
mql.addListener(update);
};
const removeListener = (mql: MediaQueryList) => {
if (mql.removeEventListener) {
mql.removeEventListener('change', update);
return;
}
mql.removeListener(update);
};
addListener(pointerMql);
addListener(hoverMql);
addListener(widthMql);
return () => {
removeListener(pointerMql);
removeListener(hoverMql);
removeListener(widthMql);
};
}, []);
useEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const updateSize = () => {
const styles = window.getComputedStyle(element);
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight);
const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0);
setContainerWidth(width > 0 ? width : element.clientWidth);
};
updateSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}
const observer = new ResizeObserver(updateSize);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const panel = panelRefs.current.get(activeTab);
if (!panel) {
setContainerHeight(null);
return;
}
const updateHeight = () => setContainerHeight(panel.offsetHeight);
updateHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
const observer = new ResizeObserver(updateHeight);
observer.observe(panel);
return () => observer.disconnect();
}, [activeTab]);
useEffect(() => {
return () => {
if (scrollEndTimer.current !== null) {
window.clearTimeout(scrollEndTimer.current);
}
if (scrollRaf.current !== null) {
cancelAnimationFrame(scrollRaf.current);
}
};
}, []);
useEffect(() => {
lastReportedIndex.current = safeActiveIndex;
}, [safeActiveIndex]);
const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => {
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
const targetLeft = index * containerWidth;
if (Math.abs(container.scrollLeft - targetLeft) <= 1) return;
container.scrollTo({ left: targetLeft, behavior });
}, [containerWidth]);
const scheduleScrollPositionUpdate = useCallback(() => {
if (scrollRaf.current !== null) return;
scrollRaf.current = window.requestAnimationFrame(() => {
scrollRaf.current = null;
setScrollPosition(scrollPositionRef.current);
});
}, []);
const handleScroll = useCallback(() => {
if (!swipeEnabled) return;
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
if (!isUserScrolling.current) {
isUserScrolling.current = true;
setIsScrolling(true);
}
const rawIndex = container.scrollLeft / containerWidth;
scrollPositionRef.current = rawIndex;
scheduleScrollPositionUpdate();
const roundedIndex = Math.round(rawIndex);
const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex));
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
lastReportedIndex.current = clampedIndex;
onTabChange(tabs[clampedIndex]);
}
if (scrollEndTimer.current !== null) {
window.clearTimeout(scrollEndTimer.current);
}
scrollEndTimer.current = window.setTimeout(() => {
const rawIndex = container.scrollLeft / containerWidth;
const nextIndex = Math.round(rawIndex);
const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
const targetLeft = clampedIndex * containerWidth;
if (Math.abs(container.scrollLeft - targetLeft) > 1) {
container.scrollTo({ left: targetLeft, behavior: 'auto' });
}
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
lastReportedIndex.current = clampedIndex;
onTabChange(tabs[clampedIndex]);
}
scrollPositionRef.current = targetLeft / containerWidth;
scheduleScrollPositionUpdate();
isUserScrolling.current = false;
setIsScrolling(false);
}, SCROLL_END_DELAY);
}, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]);
useLayoutEffect(() => {
if (!swipeEnabled) return;
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
if (isUserScrolling.current) return;
const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto';
scrollToIndex(safeActiveIndex, behavior);
hasMounted.current = true;
const currentPosition = container.scrollLeft / containerWidth;
scrollPositionRef.current = currentPosition;
setScrollPosition(currentPosition);
}, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]);
const visibleIndexes = useMemo(() => {
const indexes = new Set<number>();
if (!swipeEnabled) {
if (safeActiveIndex >= 0) {
indexes.add(safeActiveIndex);
}
return indexes;
}
const centers = new Set<number>();
centers.add(safeActiveIndex);
if (containerWidth <= 0) {
centers.add(0);
}
if (containerWidth > 0 && Number.isFinite(scrollPosition)) {
const base = Math.floor(scrollPosition);
const next = Math.ceil(scrollPosition);
centers.add(base);
centers.add(next);
}
centers.forEach((center) => {
for (let offset = -renderWindow; offset <= renderWindow; offset += 1) {
const index = center + offset;
if (index >= 0 && index < tabs.length) {
indexes.add(index);
}
}
});
return indexes;
}, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]);
const containerStyle: CSSProperties = {
height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto',
transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none',
};
if (!swipeEnabled) {
return (
<div
ref={containerRef}
className={`swipe-tabs swipe-tabs--static${className ? ` ${className}` : ''}`}
style={containerStyle}
>
<div className="swipe-tabs-track">
<div className="swipe-tabs-panel" aria-hidden={false}>
<div
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
ref={(node) => {
if (node) {
panelRefs.current.set(activeTab, node);
} else {
panelRefs.current.delete(activeTab);
}
}}
>
{renderPanel(activeTab)}
</div>
</div>
</div>
</div>
);
}
return (
<div
ref={containerRef}
className={`swipe-tabs swipe-tabs--snap${className ? ` ${className}` : ''}${isScrolling ? ' is-scrolling' : ''}`}
style={containerStyle}
onScroll={handleScroll}
>
<div className="swipe-tabs-track">
{tabs.map((tab, index) => {
const shouldRender = visibleIndexes.has(index);
return (
<div key={tab} className="swipe-tabs-panel" aria-hidden={tab !== activeTab}>
<div
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
ref={(node) => {
if (node) {
panelRefs.current.set(tab, node);
} else {
panelRefs.current.delete(tab);
}
}}
>
{shouldRender ? renderPanel(tab) : null}
</div>
</div>
);
})}
</div>
</div>
);
}