feat(router): add GSAP page transition animations

This commit is contained in:
LTbinglingfeng
2026-01-02 00:01:25 +08:00
parent 3a66dc225d
commit 946ed36af0
8 changed files with 234 additions and 30 deletions

View File

@@ -0,0 +1,34 @@
@use '@/styles/variables.scss' as *;
.page-transition {
position: relative;
flex: 1 0 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&__layer {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
flex: 1;
will-change: transform, opacity;
// During animation, exit layer uses absolute positioning
&--exit {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
}
// When both layers exist, current layer also needs positioning
&--animating &__layer:not(&__layer--exit) {
position: relative;
z-index: 0;
}
}

View File

@@ -0,0 +1,137 @@
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
}
const TRANSITION_DURATION = 0.65;
type LayerStatus = 'current' | 'exiting';
type Layer = {
key: string;
location: Location;
status: LayerStatus;
};
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ render, getRouteOrder }: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [
{
key: location.key,
location,
status: 'current',
},
]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
useEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
return typeof index === 'number' && index >= 0 ? index : null;
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
setTransitionDirection(nextDirection);
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
});
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
]);
// Run GSAP animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
if (!currentLayerRef.current) return;
const tl = gsap.timeline({
onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
},
});
// Exit animation: fly out to top (slow-to-fast)
if (exitingLayerRef.current) {
tl.fromTo(
exitingLayerRef.current,
{ y: 0, opacity: 1 },
{
y: transitionDirection === 'forward' ? '-100%' : '100%',
opacity: 0,
duration: TRANSITION_DURATION,
ease: 'power3.in', // slow start, fast end
},
0
);
}
// Enter animation: slide in from bottom (slow-to-fast)
tl.fromTo(
currentLayerRef.current,
{ y: transitionDirection === 'forward' ? '100%' : '-100%', opacity: 0 },
{
y: 0,
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'power2.in', // slow start, fast end
},
0
);
return () => {
tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
};
}, [isAnimating, transitionDirection]);
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
<div
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
>
{render(layer.location)}
</div>
))}
</div>
);
}