import { ReactNode, useCallback, useLayoutEffect, useRef, useState, } from 'react'; import { useLocation, type Location } from 'react-router-dom'; import gsap from 'gsap'; import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer'; import './PageTransition.scss'; interface PageTransitionProps { render: (location: Location) => ReactNode; getRouteOrder?: (pathname: string) => number | null; getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant; scrollContainerRef?: React.RefObject; } const VERTICAL_TRANSITION_DURATION = 0.35; const VERTICAL_TRAVEL_DISTANCE = 60; const IOS_TRANSITION_DURATION = 0.42; const IOS_ENTER_FROM_X_PERCENT = 100; const IOS_EXIT_TO_X_PERCENT_FORWARD = -30; const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100; const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30; const IOS_EXIT_DIM_OPACITY = 0.72; type Layer = { key: string; location: Location; status: LayerStatus; }; type TransitionDirection = 'forward' | 'backward'; type TransitionVariant = 'vertical' | 'ios'; export function PageTransition({ render, getRouteOrder, getTransitionVariant, scrollContainerRef, }: PageTransitionProps) { const location = useLocation(); const currentLayerRef = useRef(null); const exitingLayerRef = useRef(null); const transitionDirectionRef = useRef('forward'); const transitionVariantRef = useRef('vertical'); const exitScrollOffsetRef = useRef(0); const enterScrollOffsetRef = useRef(0); const scrollPositionsRef = useRef(new Map()); const nextLayersRef = useRef(null); const [isAnimating, setIsAnimating] = useState(false); const [layers, setLayers] = useState(() => [ { key: location.key, location, status: 'current', }, ]); const currentLayer = layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1]; const currentLayerKey = currentLayer?.key ?? location.key; const currentLayerPathname = currentLayer?.location.pathname; const resolveScrollContainer = useCallback(() => { if (scrollContainerRef?.current) return scrollContainerRef.current; if (typeof document === 'undefined') return null; return document.scrollingElement as HTMLElement | null; }, [scrollContainerRef]); useLayoutEffect(() => { if (isAnimating) return; if (location.key === currentLayerKey) return; if (currentLayerPathname === location.pathname) return; const scrollContainer = resolveScrollContainer(); const exitScrollOffset = scrollContainer?.scrollTop ?? 0; exitScrollOffsetRef.current = exitScrollOffset; scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset); enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0; 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 nextVariant: TransitionVariant = getTransitionVariant ? getTransitionVariant(currentLayerPathname ?? '', location.pathname) : 'vertical'; let nextDirection: TransitionDirection = fromIndex === null || toIndex === null || fromIndex === toIndex ? 'forward' : toIndex > fromIndex ? 'forward' : 'backward'; // When using iOS-style stacking, history POP within the same "section" can have equal route order. // In that case, prefer treating navigation to an existing layer as a backward (pop) transition. if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) { nextDirection = 'backward'; } transitionDirectionRef.current = nextDirection; transitionVariantRef.current = nextVariant; const shouldSkipExitLayer = (() => { if (nextVariant !== 'ios' || nextDirection !== 'backward') return false; const normalizeSegments = (pathname: string) => pathname .split('/') .filter(Boolean) .filter((segment) => segment.length > 0); const fromSegments = normalizeSegments(currentLayerPathname ?? ''); const toSegments = normalizeSegments(location.pathname); if (!fromSegments.length || !toSegments.length) return false; return fromSegments[0] === toSegments[0] && toSegments.length === 1; })(); setLayers((prev) => { const variant = transitionVariantRef.current; const direction = transitionDirectionRef.current; const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current'); const resolvedCurrentIndex = previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1; const previousCurrent = prev[resolvedCurrentIndex]; const previousStack: Layer[] = prev .filter((_, idx) => idx !== resolvedCurrentIndex) .map((layer): Layer => ({ ...layer, status: 'stacked' })); const nextCurrent: Layer = { key: location.key, location, status: 'current' }; if (!previousCurrent) { nextLayersRef.current = [nextCurrent]; return [nextCurrent]; } if (variant === 'ios') { if (direction === 'forward') { const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' }; nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent]; return [...previousStack, exitingLayer, nextCurrent]; } const targetIndex = prev.findIndex((layer) => layer.key === location.key); if (targetIndex !== -1) { const targetStack: Layer[] = prev .slice(0, targetIndex + 1) .map((layer, idx): Layer => { const isTarget = idx === targetIndex; return { ...layer, location: isTarget ? location : layer.location, status: isTarget ? 'current' : 'stacked', }; }); if (shouldSkipExitLayer) { nextLayersRef.current = targetStack; return targetStack; } const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; nextLayersRef.current = targetStack; return [...targetStack, exitingLayer]; } } if (shouldSkipExitLayer) { nextLayersRef.current = [nextCurrent]; return [nextCurrent]; } const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' }; nextLayersRef.current = [nextCurrent]; return [exitingLayer, nextCurrent]; }); setIsAnimating(true); }, [ isAnimating, location, currentLayerKey, currentLayerPathname, getRouteOrder, getTransitionVariant, resolveScrollContainer, layers, ]); // Run GSAP animation when animating starts useLayoutEffect(() => { if (!isAnimating) return; if (!currentLayerRef.current) return; const currentLayerEl = currentLayerRef.current; const exitingLayerEl = exitingLayerRef.current; const transitionVariant = transitionVariantRef.current; gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' }); if (exitingLayerEl) { gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' }); } const scrollContainer = resolveScrollContainer(); const exitScrollOffset = exitScrollOffsetRef.current; const enterScrollOffset = enterScrollOffsetRef.current; if (scrollContainer && exitScrollOffset !== enterScrollOffset) { scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' }); } const transitionDirection = transitionDirectionRef.current; const isForward = transitionDirection === 'forward'; const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE; const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE; const exitBaseY = enterScrollOffset - exitScrollOffset; const tl = gsap.timeline({ onComplete: () => { const nextLayers = nextLayersRef.current; nextLayersRef.current = null; setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting')); setIsAnimating(false); if (currentLayerEl) { gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' }); } if (exitingLayerEl) { gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' }); } }, }); if (transitionVariant === 'ios') { const exitToXPercent = isForward ? IOS_EXIT_TO_X_PERCENT_FORWARD : IOS_EXIT_TO_X_PERCENT_BACKWARD; const enterFromXPercent = isForward ? IOS_ENTER_FROM_X_PERCENT : IOS_ENTER_FROM_X_PERCENT_BACKWARD; if (exitingLayerEl) { gsap.set(exitingLayerEl, { y: exitBaseY, xPercent: 0, opacity: 1, }); } gsap.set(currentLayerEl, { xPercent: enterFromXPercent, opacity: 1, }); const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)'; const topLayerEl = isForward ? currentLayerEl : exitingLayerEl; if (topLayerEl) { gsap.set(topLayerEl, { boxShadow: shadowValue }); } if (exitingLayerEl) { tl.to( exitingLayerEl, { xPercent: exitToXPercent, opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1, duration: IOS_TRANSITION_DURATION, ease: 'power2.out', force3D: true, }, 0 ); } tl.to( currentLayerEl, { xPercent: 0, opacity: 1, duration: IOS_TRANSITION_DURATION, ease: 'power2.out', force3D: true, }, 0 ); } else { // Exit animation: fade out with slight movement (runs simultaneously) if (exitingLayerEl) { gsap.set(exitingLayerEl, { y: exitBaseY }); tl.to( exitingLayerEl, { y: exitBaseY + exitToY, opacity: 0, duration: VERTICAL_TRANSITION_DURATION, ease: 'circ.out', force3D: true, }, 0 ); } // Enter animation: fade in with slight movement (runs simultaneously) tl.fromTo( currentLayerEl, { y: enterFromY, opacity: 0 }, { y: 0, opacity: 1, duration: VERTICAL_TRANSITION_DURATION, ease: 'circ.out', force3D: true, onComplete: () => { if (currentLayerEl) { gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); } }, }, 0 ); } return () => { tl.kill(); gsap.killTweensOf([currentLayerEl, exitingLayerEl]); }; }, [isAnimating, resolveScrollContainer]); return (
{(() => { const currentIndex = layers.findIndex((layer) => layer.status === 'current'); const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex; const keepStackedIndex = layers .slice(0, resolvedCurrentIndex) .map((layer, index) => ({ layer, index })) .reverse() .find(({ layer }) => layer.status === 'stacked')?.index; return layers.map((layer, index) => { const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex; return (
{render(layer.location)}
); }); })()}
); }