import { MotionRuntime } from '@teleporthq/teleport-shared' /** * Generates the TqMotion wrapper around framer-motion. Motion is a CONTAINER * widget: it renders its children inside a `` and animates them * according to the same semantic contract used by the SolidJS canvas renderer * (see packages/renderer motion-runtime) — preset/easing/keyframe logic is kept * identical so the published site matches the canvas. * * Triggers: on-load (initial/animate), in-view (useInView-driven + a standing * in-viewport failsafe so content is never trapped at opacity:0 — any element * that is genuinely on screen reveals even if the IntersectionObserver never * reports it), scroll (scroll-linked: useScroll progress — windowed by * scrollOffset, optionally useSpring-smoothed by scrub — interpolates the FULL * from/to state onto the element, matching the canvas runtime), hover * (whileHover), tap (whileTap). `stagger` descends through grid/list wrappers to the real * repeated items (e.g. cards) and cascades THOSE with a per-child * delay — not the lone grid block — matching the canvas renderer. When ANY node it * would wrap comes from a runtime / (build-time opaque, so * its items can't be wrapped individually), it degrades to a single GROUP animation * rather than wrapping that node in a lone block — that block would sit between the * grid/flex container and its items and collapse them into one cell. That holds * whether the repeater is the container's only child or sits beside static siblings. * Honors prefers-reduced-motion. framer-motion * requires React 18 — the Next project plugin bumps react/react-dom accordingly. * * ## ⛔ A STAGGER MUST NOT ADD A DOM NODE (run c133d485, "How the Appointment Works") * * The cascade used to wrap every target in its own ``. The canvas does * the opposite: `runMotion` writes the tween straight onto the target elements' * inline style and adds NOTHING to the tree. So the published DOM gained a box the * canvas never had, sitting exactly between a layout container and its items — and * every declaration the author aimed at those items stopped reaching the boxes the * container was laying out: * * ```html *
*
…
x4 * ``` * * published as `.process-rail > div > .process-step-card`, so the flex items were * the anonymous wrappers (`flex: 0 1 auto`), `flex-basis: 380px` reached nothing, * each wrapper shrink-wrapped to its copy's max-content — 1500px — and the rail * measured 6096px against a 1056px viewport. Measured live on the deployed site. * The same wrapper is why `:nth-child()` dies once published (see * `bake-structural-pseudo-classes.ts` in the generation service). * * The cascade is therefore applied IN PLACE: each target is cloned with the * animated CSS on its own `style`, driven by a CSS transition whose delay carries * the per-index offset. Same curves, same keyframes, same order — and the * published tree is the canvas tree. Two shapes fall back to the single group * animation rather than re-introducing a wrapper: a target that is not a plain DOM * element (a component is free to drop an injected `style`), and a `repeat` other * than 0 (a CSS transition plays exactly once). */ export const generateMotionComponentCode = (): string => { return `import React from 'react' import { motion, useInView, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from 'framer-motion' ${MotionRuntime.motionEngineSource()} const buildAnimProps = (trigger, fromVars, toVars, transition, revealed, played) => { switch (trigger) { case 'load': // Driven by "played" (true from the first frame after mount) rather than // framer's initial -> animate pair. A page transition wraps the page in an // AnimatePresence with initial={false}, which hands every motion component // under it its animate state at once and skips the mount animation: an // entrance never played on the first page a visitor opened, and a loop // (repeat -1) never started at all. A change of animate always plays. return { initial: fromVars, animate: played ? toVars : fromVars, transition } case 'in-view': // Driven by "revealed" (useInView OR the timed in-viewport failsafe) rather // than a passive viewport-gated prop, so an already-in-view element (e.g. a // top hero whose observer misses the initial intersection) can never stay hidden. return { initial: fromVars, animate: revealed ? toVars : fromVars, transition } case 'hover': return { whileHover: toVars, transition } case 'tap': return { whileTap: toVars, transition } default: return { initial: fromVars, animate: toVars, transition } } } // Find the elements a stagger should cascade across. Content is usually wrapped as //
{items.map(...)}
, so the only child // is a grid wrapper. Descend through single-child wrappers to the real repeated // items, cascade THOSE (so cards cascade — not the lone block), keep the wrappers. // // Returns null when the descent bottoms out at a single, non-cascadable node — most // importantly a runtime (array-mapper), which is self-closing at build // time (props.children is undefined) yet renders many siblings at runtime. Wrapping // that lone block in a motion.div would drop a single block element BETWEEN a // grid/flex container and its repeated items and collapse them into one cell. On null // the caller animates the whole subtree as one group instead — the wrapper then sits // OUTSIDE the layout container, so the repeated items stay its direct layout children. // // ⛔ AND A SIBLING BESIDE THE REPEATER DEFEATED THAT GUARD (run 75860d32, // "Start by Concern"). The lone-child test only ever fired for a container whose // ONLY child is the mapper. That page's grid held the mapper AND one static card: // //
// grid-template-columns: repeat(3, 1fr) // {loading &&
} // {!loading && } // 4 cards //
//
// // so the array is length 2 at runtime, every branch was wrapped, and the four // cards landed INSIDE ONE motion.div — one grid cell holding a vertical stack, // the pivot in the next, and the third column empty for the height of the band. // The user's read: "4 big cards to the left and only one small card to the // right … the right part of the page looks really empty." // // A repeater among siblings is the SAME defect as a repeater alone, so it takes // the same remedy: no stagger, one group animation, layout untouched. Losing a // cascade is a smaller loss than losing the layout. const mapStaggerTargets = (nodes, wrap, depth) => { // Renders MANY siblings at runtime from ONE build-time element: // (array-mapper) and (a table-backed mapper's fetch wrapper). // Both keep their content behind a render prop, so props.children is undefined // and the descent below can never reach the items to cascade them. const rendersManySiblings = (node) => !!node && React.isValidElement(node) && !!node.props && (node.props.renderItem != null || node.props.renderSuccess != null) // The cascade is written onto each target's own \`style\`, so a target has to be // a plain DOM element: a component owns its rendering and is free to drop an // injected style prop (next/link does exactly that), which would leave the item // stuck at its resting state. One such target disqualifies the whole cascade — // a partial cascade is a page where some cards never appear. const stylableInPlace = (node) => !!node && React.isValidElement(node) && typeof node.type === 'string' const arr = React.Children.toArray(nodes) if (arr.length > 1 || depth >= 3) { if (arr.some(rendersManySiblings)) { return null } if (!arr.every(stylableInPlace)) { return null } return arr.map((child, index) => wrap(child, index)) } const only = arr[0] if (rendersManySiblings(only)) { return null } if (only && React.isValidElement(only) && only.props && only.props.children != null) { const inner = mapStaggerTargets(only.props.children, wrap, depth + 1) if (inner == null) { return null } return React.cloneElement(only, {}, inner) } return null } const TqMotion = ({ preset = 'fade-in', trigger = 'in-view', duration = 0.6, delay = 0, easing = 'ease-out', repeat = 0, repeatType = 'loop', inViewOnce = true, inViewAmount = 0.3, stagger = 0, distance = 40, from = null, to = null, scrub = 0, scrollOffset = 'pass', style, children, ...rest }) => { const ref = React.useRef(null) const shouldReduceMotion = useReducedMotion() // The server cannot know that the visitor asks for less motion, so it renders // the animated element with its starting style. React keeps that markup for an // element the first render here draws differently, and says nothing in // production: every entrance stayed invisible for those visitors. The still // element is drawn once the page is mounted. const [mounted, setMounted] = React.useState(false) React.useEffect(() => setMounted(true), []) const still = mounted && Boolean(shouldReduceMotion) // The wrapper is a stacking context at rest, exactly as it is mid-animation // and exactly as the canvas draws it (the canvas wrapper animates via // transform, which creates one). Framer drops the transform once an entrance // ends, so without this a later static sibling — the hero photo — painted // over the headline in the export while the canvas showed it on top. const wrapperStyle = { isolation: 'isolate', ...(style || {}) } // in-view reveal is driven by useInView + a STANDING in-viewport failsafe, so // a reveal can never trap content at opacity:0. // // This used to be a single setTimeout that checked once, roughly a second // after mount. That only ever rescued content which happened to be on screen // at that instant: everything below the fold got its one check while // off-screen, saw itself invisible, and was never checked again. From then on // the IntersectionObserver was the only thing that could reveal it, so one // missed callback left an entire section at opacity 0 permanently — a // 2162px-tall product grid sat invisible while dead-centre in the viewport, // 41% on screen, for as long as anyone cared to wait. // // The check now stands for the element's lifetime, coalesced into a frame and // passive so scrolling stays cheap, and it applies the SAME threshold // useInView was given. It therefore fires exactly when the observer should // have and never earlier — an animation's timing is unchanged when the // observer works, which is almost always. const inView = useInView(ref, { once: Boolean(inViewOnce), amount: Number(inViewAmount) || 0.3 }) const [forceReveal, setForceReveal] = React.useState(false) const revealed = inView || forceReveal const revealedRef = React.useRef(false) React.useEffect(() => { revealedRef.current = revealed }, [revealed]) // "The load animation may start now": the resting state is what the first // paint (and the SSR html) carries, and the animation starts on the next // frame. The in-place cascade needs it because a CSS transition only runs // when the value CHANGES; the framer path needs it because a mount animation // is skipped under a page transition (see buildAnimProps). const [played, setPlayed] = React.useState(false) React.useEffect(() => { if (trigger !== 'load') { return undefined } const frame = requestAnimationFrame(() => setPlayed(true)) return () => cancelAnimationFrame(frame) }, [trigger]) React.useEffect(() => { if (trigger !== 'in-view') { return undefined } const once = Boolean(inViewOnce) const amount = Number(inViewAmount) || 0.3 let frame = 0 const check = () => { // Already done: nothing to measure, so a revealed element costs nothing // per scroll frame beyond this comparison. if (once && revealedRef.current) { return } const el = ref.current if (!el || typeof el.getBoundingClientRect !== 'function') { return } const rect = el.getBoundingClientRect() const vh = window.innerHeight || document.documentElement.clientHeight || 0 const vw = window.innerWidth || document.documentElement.clientWidth || 0 const fraction = visibleFraction(rect, vh, vw) const threshold = Math.min(amount, reachableFraction(rect, vh, vw) * 0.9) const visible = fraction > 0 && fraction >= threshold // With inViewOnce the reveal latches; without it, visibility is tracked // both ways so the author's repeat-on-re-entry behaviour is preserved. setForceReveal(function (previous) { return once ? previous || visible : visible }) } const schedule = () => { if (frame) { return } frame = requestAnimationFrame(function () { frame = 0 check() }) } const timeoutId = setTimeout(check, (Number(duration) || 0.6) * 1000 + 600) window.addEventListener('scroll', schedule, { passive: true }) window.addEventListener('resize', schedule, { passive: true }) return () => { clearTimeout(timeoutId) if (frame) { cancelAnimationFrame(frame) } window.removeEventListener('scroll', schedule) window.removeEventListener('resize', schedule) } }, [trigger, inViewOnce, inViewAmount, duration]) const dist = Number(distance) || 0 const base = presetStates(preset, dist) const fromVars = { ...base.from, ...(from || {}) } const toVars = { ...base.to, ...(to || {}) } const ease = EASINGS[easing] || EASINGS['ease-out'] const repeatCount = Number(repeat) < 0 ? Infinity : Number(repeat) || 0 const transition = { duration: Number(duration) || 0.6, delay: Number(delay) || 0, ease, repeat: repeatCount, repeatType, } // Scroll-linked motion is one trigger out of five, but the hooks have to run // on every render. Passing a target when we are not going to use it makes // framer measure this element against the scroll container on every scroll // frame — and warn about the container being position:static — once per // motion node on the page. Targetless useScroll shares one window listener // and measures nothing. const scrollOptions = trigger === 'scroll' ? { target: ref, offset: SCROLL_OFFSET_RANGES[scrollOffset] || SCROLL_OFFSET_RANGES.pass } : {} const { scrollYProgress } = useScroll(scrollOptions) const scrubSeconds = Number(scrub) || 0 const smoothedProgress = useSpring(scrollYProgress, { duration: Math.max(1, scrubSeconds * 1000), bounce: 0, }) const scrollProgress = scrubSeconds > 0 ? smoothedProgress : scrollYProgress // Progress writes styles straight to the DOM node (no re-render per frame), // interpolating the FULL from/to state — not just y — exactly like the // canvas runtime does. useMotionValueEvent(scrollProgress, 'change', (p) => { if (trigger === 'scroll' && !shouldReduceMotion) { applyScrollState(ref.current, fromVars, toVars, p) } }) React.useEffect(() => { if (trigger === 'scroll' && !shouldReduceMotion) { applyScrollState(ref.current, fromVars, toVars, scrollProgress.get()) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [trigger, shouldReduceMotion]) // A row that slides by itself stands still for a visitor who asks for less // motion, and its frame lets them swipe it (letMarqueeSwipe). React.useEffect(() => { if (still) { letMarqueeSwipe(ref.current) } }, [still]) if (still) { return (
{children}
) } if (trigger === 'scroll') { return (
{children}
) } // A CSS transition plays exactly once, so a repeating stagger keeps the framer // path (and its wrapper). Entrance cascades — every stagger the generator has // ever emitted — run in place. if (Number(stagger) > 0 && repeatCount === 0 && (trigger === 'load' || trigger === 'in-view')) { const fromCss = cssFromState(fromVars) const toCss = cssFromState(toVars) // Transition every property either state touches, so a preset that animates // only \`filter\` (blur-in) is covered exactly like one that animates opacity. const animatedProps = Object.keys(fromCss).concat( Object.keys(toCss).filter((prop) => fromCss[prop] === undefined) ) const easeCss = 'cubic-bezier(' + ease.join(', ') + ')' const durationSeconds = Number(duration) || 0.6 const atRest = trigger === 'load' ? played : revealed const styleChild = (child, index) => { const delaySeconds = (Number(delay) || 0) + index * Number(stagger) const childTransition = animatedProps .map( (prop) => prop + ' ' + durationSeconds + 's ' + easeCss + ' ' + delaySeconds + 's' ) .join(', ') return React.cloneElement(child, { key: index, style: { ...(child.props.style || {}), ...(atRest ? toCss : fromCss), transition: childTransition, }, }) } const staggerTargets = mapStaggerTargets(children, styleChild, 0) // staggerTargets is null when there are no real per-item targets to cascade // (e.g. the items render from a runtime , or a target is a component // that may drop an injected style). Fall through to the single group animation // below so the wrapper stays OUTSIDE the grid/flex container and its repeated // items remain direct layout children (no collapsed layout). if (staggerTargets != null) { // motion.div (animation-less), NOT a plain div: this branch and the group // fall-through below must render the SAME outer element type. A page whose // loading conditional resolves can flip which branch runs — with differing // types React REMOUNTS the outer element, and framer's useInView keeps // observing the detached original, so the group variant below could never // reveal (the trapped-at-opacity-0 "Making Process" defect). A constant // element type keeps the same DOM node across the swap and the observer live. return ( {staggerTargets} ) } } const animProps = buildAnimProps(trigger, fromVars, toVars, transition, revealed, played) return ( {children} ) } export default TqMotion ` }