import { PageTransition } from '@teleporthq/teleport-shared' import { PAGE_TRANSITION_SLIDE_PX, pageTransitionCoverColor, resolvePageTransitionOptions, sanitizePageTransitionCustom, } from './page-transition-variants' import { REVEAL_ON_SCREEN_SOURCES } from './reveal-on-screen-source.generated' export type PageTransitionConfig = PageTransition.PageTransitionConfig const EASING_CURVES = PageTransition.PAGE_TRANSITION_EASING_CURVES const { BACK_ATTR, ORIGIN_X, ORIGIN_Y, morphHelpersSource, viewTransitionPlan } = PageTransition /** * `components/tq-page-transition.js` — wraps the mounted page in `_app` so a * route change plays the site's transition: the leaving page runs `exit`, the * arriving one runs `initial → animate` (AnimatePresence, mode "wait"). * * Direction-aware: the browser's back button plays the preset the other way * (`reversePageTransitionPreset`), and the Circle reveal grows from the last * pointer press. Both are decided at `routeChangeStart`, BEFORE the key * changes, so the page about to leave is re-rendered with the right exit. * * Five things a naive wrapper gets wrong, handled here: * - The leaving page is FROZEN in place (position: fixed at its scroll offset) * for its exit, because Next scrolls the window to the top the moment the new * route resolves — otherwise a long page visibly jumps to its own top while * fading out. * - Only real page changes transition: query-only and hash navigations keep * the same key, so a filter change or an in-page anchor never re-plays it. * - `prefers-reduced-motion` renders the page plainly — no wrapper animation. * - A reveal (Circle, the Wipes) opens the arriving page OVER the leaving one, * which holds still underneath until the reveal has fully opened. Played one * after the other, the leaving page was wiped away to the site's bare * background first — a white sheet between every two pages. Where the * browser has View Transitions the whole change is one (below); elsewhere * the two pages overlap (AnimatePresence "sync"), the arriving page laid * above the frozen one and the leaving page kept in the document until the * arrival's animation completes (`HoldPage`). * - A reveal's clip-path is measured on the screen, not on the page, and never * stays on the page once it has played, nor on the first page * (`revealOnScreen`). Measured on a long page, the circle covered the screen * for nearly all of its run and the new page popped in (a flicker); left on * the whole page, the clip broke the fixed header's blur and the pinned * scenes while scrolling. * - The arriving page plays once the pictures on its first screen are decoded * (300 ms at most). Played at once, it showed its text on a bare ground and * its photographs popped in a moment later — a flicker at every change. * * The first load never animates in (`initial={false}`): a site should appear, * not perform, on arrival. * * Two kinds of navigation are played as a View Transition instead, with the * stylesheet the static HTML export plays between its pages * (view-transition-css.ts and morph-source.ts in teleport-shared): every page * change on a reveal preset, and following a link with a picture in it to a * page showing the same picture (a card into its details hero), where the * picture flies from one page into the other — which only a View Transition * can do. The browser takes a picture of the leaving page at its next frame, * so that page stays in the document until then (a prefetched route can * resolve sooner), and pictures the arriving page once it is mounted and its * first screen's pictures are decoded. A page that opted out, reduced motion * and a browser without View Transitions keep the animation above. */ export const generatePageTransitionComponentCode = (config: PageTransitionConfig): string => { const curve = EASING_CURVES[config.easing] || EASING_CURVES['ease-out'] const duration = Number.isFinite(config.duration) && config.duration > 0 ? config.duration : 0.35 const skipRoutes = Array.isArray(config.skipRoutes) ? config.skipRoutes.filter((route) => typeof route === 'string' && route.length > 0) : [] const preset = String(config.preset).replace(/[^a-z-]/g, '') const chosen = config.options && typeof config.options === 'object' ? Object.fromEntries( Object.entries(config.options).filter(([, value]) => typeof value === 'string') ) : undefined const options = resolvePageTransitionOptions(preset, chosen) // "Design your own" carries its states in the UIDL; duration and easing already sit in their own fields. const custom = preset === 'custom' ? sanitizePageTransitionCustom({ durationSeconds: duration, easing: config.easing, arrive: config.custom?.arrive as never, leave: config.custom?.leave as never, }) : null // What plays as a View Transition here: every reveal, and the flight when the // author kept it on; a slide with the flight off needs no stylesheet at all. const plan = viewTransitionPlan(config, { crossDocument: false }) const vtCss = plan && (plan.revealsOver || plan.flies) ? plan.css : '' const flies = !!plan && plan.flies return `import React from 'react' import { useRouter } from 'next/router' import { AnimatePresence, motion, usePresence } from 'framer-motion' ${PageTransition.PAGE_TRANSITION_HELPER_SOURCES.reversePageTransitionPreset} ${PageTransition.PAGE_TRANSITION_HELPER_SOURCES.pageTransitionVariants} ${PageTransition.PAGE_TRANSITION_HELPER_SOURCES.pageTransitionCover} ${PageTransition.PAGE_TRANSITION_HELPER_SOURCES.pageTransitionSkipPattern} ${REVEAL_ON_SCREEN_SOURCES.revealOnScreen} ${morphHelpersSource()} const PRESET = '${preset}' // The preset's choices, resolved to words the variants understand. const OPTIONS = ${JSON.stringify(options)} const CUSTOM = ${JSON.stringify(custom)} const ORIGIN_FROM_POINTER = ${options.origin !== 'center'} const DURATION = ${duration} const EASE = [${curve.join(', ')}] const SLIDE_PX = ${PAGE_TRANSITION_SLIDE_PX} const CENTER = { reverse: false, skip: false, originX: '50%', originY: '50%' } const PANELS = pageTransitionCover(PRESET) // Pages that opted out: they appear instantly; leaving them still plays. const SKIP_ROUTES = ${JSON.stringify(skipRoutes)} const INSTANT = { initial: {}, animate: {}, exit: {} } // A reveal (Circle, the Wipes): the arriving page opens over the leaving one. // Without View Transitions it runs frame by frame, framer writing each // clip-path onto the page: run by the browser (framer's default for clip-path), // the animation ends one frame before framer writes its last value, and for // that frame the page shows its starting clip — the whole page blinks away as // the reveal completes. An onUpdate handler, even one that does nothing, keeps // the animation in framer's own loop. const REVEAL = (() => { const variants = pageTransitionVariants(PRESET, SLIDE_PX, CENTER, OPTIONS, CUSTOM || undefined) return !!(variants && variants.animate.clipPath) })() const stepByFrame = () => {} // Without View Transitions the two pages overlap for a reveal: the arriving // page is laid above the frozen one (a positioned box would paint on top of it). const OVER = { position: 'relative', zIndex: 1 } const SKIP_PATTERNS = SKIP_ROUTES.map(pageTransitionSkipPattern) const isSkippedRoute = (key) => SKIP_PATTERNS.some((pattern) => pattern.test(key)) // The cover family paints its panels in the theme colour the editor chose. const PANEL_COLOR = '${pageTransitionCoverColor(options.color)}' const PANEL_BASE = { position: 'fixed', top: 0, bottom: 0, zIndex: 2147483000, pointerEvents: 'none', background: PANEL_COLOR } const panelBounds = (panel) => panel.side === 'left' ? { left: 0, right: '50%' } : panel.side === 'right' ? { left: '50%', right: 0 } : { left: 0, right: 0 } const routeKeyOf = (url) => String(url || '').split(/[?#]/)[0] // The page changes played as a View Transition (a reveal; a flying picture), as // the static export plays them. const VT_CSS = ${JSON.stringify(vtCss)} const FLIES = ${flies} // A page that takes longer than this to arrive switches without the transition. const ARRIVAL_WAIT_MS = 3000 // An arriving picture still loading is waited for this long, so the flight lands on it. const MORPH_DECODE_MS = 300 // The arriving page waits this long at most for the pictures on its first screen. const PICTURE_WAIT_MS = 300 const prefersLessMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) // Settles once the pictures on the arriving page's first screen are decoded, or // after PICTURE_WAIT_MS: an image the page asks to decode off the main thread // paints a few frames after the rest of the page. const picturesReady = (node) => { const pictures = Array.prototype.filter.call(node.querySelectorAll('img'), (image) => { const box = image.getBoundingClientRect() return box.bottom > 0 && box.top < window.innerHeight && typeof image.decode === 'function' }) return Promise.race([ Promise.all(pictures.map((image) => image.decode().catch(() => {}))), new Promise((resolve) => setTimeout(resolve, PICTURE_WAIT_MS)), ]) } // Keeps the leaving page in the document while it is still needed: until the // browser has pictured it (a View Transition), or until the page revealed over // it has fully opened (a reveal without View Transitions). const HoldPage = ({ pageKey, changeRef, revealRef }) => { const [isPresent, safeToRemove] = usePresence() React.useEffect(() => { if (isPresent) { return } const change = changeRef.current const reveal = revealRef.current if (change && change.leavingKey === pageKey) { change.captured.then(safeToRemove) } else if (reveal && reveal.leavingKey === pageKey) { reveal.opened.then(safeToRemove) } else { safeToRemove() } }, [isPresent]) return null } const TqPageTransition = ({ children }) => { const router = useRouter() const routeKey = routeKeyOf(router.asPath) const routeKeyRef = React.useRef(routeKey) routeKeyRef.current = routeKey const [frozen, setFrozen] = React.useState(null) const [context, setContext] = React.useState(CENTER) const [panelPhase, setPanelPhase] = React.useState('idle') // The page being revealed over the leaving one without View Transitions. const [overKey, setOverKey] = React.useState(null) // The page whose arrival may play: the first page at once, every later one // once its first screen's pictures are ready. const [readyKey, setReadyKey] = React.useState(routeKey) const readyKeyRef = React.useRef(routeKey) const pageRef = React.useRef(null) const popRef = React.useRef(false) const pointerRef = React.useRef(null) const clickedRef = React.useRef(null) const changeRef = React.useRef(null) const revealRef = React.useRef(null) const variants = React.useMemo( () => context.skip || context.morph ? INSTANT : revealOnScreen(pageTransitionVariants(PRESET, SLIDE_PX, context, OPTIONS, CUSTOM || undefined), context.screen), [context] ) // The arriving page is in the document: wait for the pictures on its first // screen (and, in a View Transition, for its twin of the flying picture), // then let it play — or let the browser picture the new state. const setPage = React.useCallback((node) => { pageRef.current = node const key = node ? node.getAttribute('data-tq-page-transition') : null if (key === null || key === readyKeyRef.current) { return } readyKeyRef.current = key const change = changeRef.current const landing = change && !change.landed && node !== change.leaving ? change : null let twin = null if (landing) { landing.landed = true tqMorphClear() twin = landing.src ? tqMorphTarget(landing.src) : null if (twin) { twin.style.viewTransitionName = TQ_MORPH_NAME } } const twinReady = twin && !twin.complete && typeof twin.decode === 'function' ? Promise.race([ twin.decode().catch(() => {}), new Promise((resolve) => setTimeout(resolve, MORPH_DECODE_MS)), ]) : Promise.resolve() Promise.all([picturesReady(node), twinReady]).then(() => { // A later page may have arrived meanwhile; this one's turn has passed. if (readyKeyRef.current === key) { setReadyKey(key) setPanelPhase((phase) => (phase === 'covered' ? 'uncovering' : phase)) } if (landing) { landing.arrive() } }) }, []) React.useEffect(() => { const onPointerDown = (event) => { pointerRef.current = { x: event.clientX, y: event.clientY } } const onClick = (event) => { clickedRef.current = event.target && event.target.closest ? event.target.closest('a[href]') : null } window.addEventListener('pointerdown', onPointerDown, true) window.addEventListener('click', onClick, true) return () => { window.removeEventListener('pointerdown', onPointerDown, true) window.removeEventListener('click', onClick, true) } }, []) React.useEffect(() => { router.beforePopState(() => { popRef.current = true return true }) const root = document.documentElement const abandon = (change) => { if (!change.landed) { change.landed = true change.transition.skipTransition() change.arrive() } } // The page change as a View Transition: every reveal, and a picture in the // followed link that the next page shows too (going forward only). const startViewChange = (url, pointer, reverse) => { if (!VT_CSS || !pageRef.current || typeof document.startViewTransition !== 'function' || prefersLessMotion()) { return false } const image = FLIES && !reverse ? tqMorphSource(url, clickedRef.current) : null if (!image && !REVEAL) { return false } const change = { leavingKey: routeKeyRef.current, leaving: pageRef.current, src: image ? image.currentSrc || image.src : null, landed: false } change.captured = new Promise((resolve) => { change.onCaptured = resolve }) const arrived = new Promise((resolve) => { change.arrive = resolve }) tqMorphClear() if (image) { image.style.viewTransitionName = TQ_MORPH_NAME } if (reverse) { root.setAttribute('${BACK_ATTR}', 'back') } if (pointer) { root.style.setProperty('${ORIGIN_X}', pointer.x + 'px') root.style.setProperty('${ORIGIN_Y}', pointer.y + 'px') } const settle = () => { if (changeRef.current === change) { changeRef.current = null tqMorphClear() root.removeAttribute('${BACK_ATTR}') root.style.removeProperty('${ORIGIN_X}') root.style.removeProperty('${ORIGIN_Y}') } } changeRef.current = change try { change.transition = document.startViewTransition(() => { change.onCaptured() return arrived }) } catch (error) { settle() return false } change.transition.ready.catch(() => {}) change.transition.finished.then(settle, settle) setTimeout(() => abandon(change), ARRIVAL_WAIT_MS) return true } const onRouteChangeStart = (url) => { if (routeKeyOf(url) === routeKeyRef.current) { return } const reverse = popRef.current popRef.current = false // A skipped page appears instantly; leaving it still plays the site's transition. // A visitor who asks for less motion gets every page that way. It is read // here, never while rendering: the server cannot know it, and a first render // that differs from the server's makes React throw the page away. const skip = prefersLessMotion() || isSkippedRoute(routeKeyOf(url)) const pointer = reverse || !ORIGIN_FROM_POINTER ? null : pointerRef.current // A reveal is measured on this screen (revealOnScreen); without a press it // grows from the screen's centre, not the page's. const screen = { width: window.innerWidth, height: window.innerHeight } const origin = pointer || { x: screen.width / 2, y: screen.height / 2 } const next = { reverse, skip, originX: origin.x + 'px', originY: origin.y + 'px', screen } if (!skip && startViewChange(url, pointer, reverse)) { setContext(Object.assign({}, next, { morph: true })) return } setContext(next) if (PANELS && !skip) { setPanelPhase('covering') } if (REVEAL && !skip) { // Without View Transitions the two pages overlap: the leaving page is // held under the arriving one until that has fully opened (or, should // the arrival never complete, a little after its time). const reveal = { leavingKey: routeKeyRef.current, arrivingKey: routeKeyOf(url) } reveal.opened = new Promise((resolve) => { reveal.open = resolve }) reveal.finish = () => { reveal.open() if (revealRef.current === reveal) { revealRef.current = null setOverKey(null) } } revealRef.current = reveal setOverKey(reveal.arrivingKey) setTimeout(reveal.finish, DURATION * 1000 + PICTURE_WAIT_MS + 1000) } setFrozen({ key: routeKeyRef.current, top: window.scrollY || 0 }) } const onRouteChangeError = () => { if (changeRef.current) { abandon(changeRef.current) } } router.events.on('routeChangeStart', onRouteChangeStart) router.events.on('routeChangeError', onRouteChangeError) return () => { router.events.off('routeChangeStart', onRouteChangeStart) router.events.off('routeChangeError', onRouteChangeError) router.beforePopState(() => true) } }, [router]) if (!variants) { return children } const frozenStyle = frozen && frozen.key === routeKey ? { position: 'fixed', top: -frozen.top, left: 0, right: 0 } : undefined const pageStyle = frozenStyle || (overKey === routeKey ? OVER : undefined) const onExitComplete = () => { setFrozen(null) // The panels open once the arriving page's pictures are ready (setPage). if (PANELS && panelPhase === 'covering') { setPanelPhase('covered') } } // The page revealed over the leaving one has fully opened: let the leaving page go. const onPageAnimationComplete = (definition) => { const reveal = revealRef.current if (definition === 'animate' && reveal && reveal.arrivingKey === routeKey) { reveal.finish() } } return ( <> {children} {VT_CSS ? : null} {PANELS ? PANELS.map((panel, index) => (