Spaces:
Sleeping
Sleeping
| 'use client'; | |
| /** | |
| * PageTransition | |
| * -------------- | |
| * Wraps every Next.js App Router page and: | |
| * | |
| * 1. **Slide-in on navigation** — when the route changes, the new page slides | |
| * in from right-to-left (x: 100% -> 0). This is the "opening a new page" | |
| * animation the user asked for. | |
| * | |
| * 2. **iOS-style swipe-back gesture** — when the user starts a touch within | |
| * the leftmost ~28px of the screen and drags rightward, the page follows | |
| * their finger (sliding left-to-right, as if being pushed off-screen). | |
| * Releasing past ~120px commits the back navigation; otherwise the page | |
| * snaps back. This is the "exiting a page" animation the user asked for | |
| * and matches the iOS native feel. | |
| * | |
| * 3. Sizing safety — the shell is pinned to `width:100%` and overflow is | |
| * hidden on <main>, so the slide animation can never introduce a | |
| * horizontal scrollbar. | |
| * | |
| * The component is intentionally self-contained: it owns its own motion | |
| * values, its own touch listeners, and its own route-change subscription | |
| * (via usePathname). It does NOT use AnimatePresence because the App Router | |
| * swaps `children` in place — keyed remounting via `key={pathname}` is enough | |
| * to trigger the entrance animation on every navigation. | |
| */ | |
| import { motion, useMotionValue, useTransform, animate, type PanInfo } from 'framer-motion'; | |
| import { usePathname, useRouter } from 'next/navigation'; | |
| import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react'; | |
| // ---- Tunable constants ------------------------------------------------------ | |
| const EDGE_ZONE_PX = 28; // Touch must start within this many px of the left edge. | |
| const COMMIT_THRESHOLD_PX = 120; // Drag this far right to actually go back. | |
| const DRAG_MAX_PX = 280; // Visual cap on how far the page will follow the finger. | |
| const SLIDE_DURATION = 0.32; // seconds — entrance animation length | |
| const SLIDE_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; // iOS-like ease | |
| // Module-level flag — set to true right before a swipe-back commit so the | |
| // NEXT mount of <PageTransition> knows to skip its slide-in animation. | |
| // (The user already saw the previous page revealed as they dragged; replaying | |
| // a slide-in would feel like a glitch.) | |
| let suppressNextEntrance = false; | |
| export function PageTransition({ children }: { children: ReactNode }) { | |
| const pathname = usePathname(); | |
| const router = useRouter(); | |
| // Single source of truth for the shell's horizontal position. | |
| const x = useMotionValue(0); | |
| // Dim the page slightly as it slides off — sells the "card being pushed | |
| // over the previous page" effect. | |
| const opacity = useTransform(x, [0, DRAG_MAX_PX], [1, 0.7]); | |
| // Subtle scale on the underlying card so the depth reads correctly. | |
| const scale = useTransform(x, [0, DRAG_MAX_PX], [1, 0.96]); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const touchState = useRef<{ | |
| startX: number; | |
| startY: number; | |
| active: boolean; | |
| horizontal: boolean | null; | |
| } | null>(null); | |
| // ----- Entrance animation: slide in from the right (x = innerWidth -> 0) -- | |
| // Runs on every pathname change because key={pathname} remounts the shell. | |
| useEffect(() => { | |
| if (typeof window === 'undefined') return; | |
| // If this mount immediately follows a swipe-back commit, skip the | |
| // slide-in animation — the user already saw the previous page revealed. | |
| if (suppressNextEntrance) { | |
| suppressNextEntrance = false; | |
| x.set(0); | |
| return; | |
| } | |
| // Default forward navigation: slide in from the right. | |
| x.set(window.innerWidth); | |
| const controls = animate(x, 0, { | |
| duration: SLIDE_DURATION, | |
| ease: SLIDE_EASE, | |
| }); | |
| return () => controls.stop(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [pathname]); | |
| // ----- Swipe-back gesture handlers ----------------------------------------- | |
| function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) { | |
| if (typeof window === 'undefined') return; | |
| const t = e.touches[0]; | |
| if (!t) return; | |
| // Only begin tracking if the touch began inside the left edge zone. | |
| if (t.clientX > EDGE_ZONE_PX) { | |
| touchState.current = null; | |
| return; | |
| } | |
| touchState.current = { | |
| startX: t.clientX, | |
| startY: t.clientY, | |
| active: true, | |
| horizontal: null, // undecided yet — we decide after the first move | |
| }; | |
| } | |
| function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) { | |
| const state = touchState.current; | |
| if (!state || !state.active) return; | |
| const t = e.touches[0]; | |
| if (!t) return; | |
| const dx = t.clientX - state.startX; | |
| const dy = t.clientY - state.startY; | |
| // Decide gesture direction once we have enough movement. | |
| if (state.horizontal === null) { | |
| const absDx = Math.abs(dx); | |
| const absDy = Math.abs(dy); | |
| if (absDx < 8 && absDy < 8) return; // ignore micro-jitter | |
| // Vertical-dominant movement = the user is scrolling the page, not | |
| // swiping back. Release control to the browser. | |
| if (absDy > absDx) { | |
| state.active = false; | |
| return; | |
| } | |
| state.horizontal = true; | |
| setIsDragging(true); | |
| } | |
| if (!state.horizontal) return; | |
| // Block the page from scrolling while we drag horizontally. | |
| if (e.cancelable) e.preventDefault(); | |
| // Only allow rightward movement, and cap it so the page doesn't fly off. | |
| const clamped = Math.max(0, Math.min(dx, DRAG_MAX_PX)); | |
| x.set(clamped); | |
| } | |
| function onTouchEnd() { | |
| const state = touchState.current; | |
| touchState.current = null; | |
| if (!state || !state.active || !state.horizontal) { | |
| // No active drag — nothing to commit / cancel. | |
| setIsDragging(false); | |
| return; | |
| } | |
| const current = x.get(); | |
| if (current >= COMMIT_THRESHOLD_PX) { | |
| // Commit: animate the rest of the way off-screen, then go back. | |
| const target = typeof window !== 'undefined' ? window.innerWidth : DRAG_MAX_PX; | |
| animate(x, target, { | |
| duration: 0.22, | |
| ease: [0.4, 0, 1, 1], | |
| onComplete: () => { | |
| // Tell the next <PageTransition> mount (the page we're going back | |
| // to) to skip its slide-in animation — the gesture already revealed it. | |
| suppressNextEntrance = true; | |
| router.back(); | |
| // Safety net in case the route change doesn't remount the shell | |
| // (e.g. navigating to the same pathname). Reset on next frame. | |
| requestAnimationFrame(() => x.set(0)); | |
| setIsDragging(false); | |
| }, | |
| }); | |
| } else { | |
| // Cancel: snap back to 0. | |
| animate(x, 0, { | |
| duration: 0.24, | |
| ease: SLIDE_EASE, | |
| onComplete: () => setIsDragging(false), | |
| }); | |
| } | |
| } | |
| // ----- Render -------------------------------------------------------------- | |
| return ( | |
| <motion.div | |
| key={pathname} | |
| className={`page-shell${isDragging ? ' is-dragging' : ''}`} | |
| style={{ x, opacity, scale }} | |
| onTouchStart={onTouchStart} | |
| onTouchMove={onTouchMove} | |
| onTouchEnd={onTouchEnd} | |
| onTouchCancel={onTouchEnd} | |
| // Prevent the browser from intercepting the gesture as a native | |
| // overscroll/refresh once we're committed to a horizontal drag. | |
| // (Per-gesture preventDefault in onTouchMove handles the actual blocking; | |
| // this attribute is the belt-and-braces version.) | |
| data-swipe-back="true" | |
| > | |
| {children} | |
| </motion.div> | |
| ); | |
| } | |
| // Re-export PanInfo so downstream consumers can type their own handlers if needed. | |
| export type { PanInfo }; | |