docs
    site-kit: Motion
    v7.2.0.md

    Motion — @sonordev/site-kit/motion

    The Upforge motion standard: highly interactive, lean, and designed from day one for SEO/AEO. Three tiers, three subpaths, so a site only installs and ships what it imports.

    TierImportCost (gzipped)When
    0@sonordev/site-kit/motion~2KB, zero depsEvery site
    1@sonordev/site-kit/motion/gsap+46KB (gsap + ScrollTrigger), at idleSequenced timelines, SplitText, Flip
    2@sonordev/site-kit/motion/three+180KB (three), on approachFlagship builds with a WebGL scene

    The reference build is upforgelabs.com: a full three.js flythrough that scores 95 on mobile Lighthouse. Every primitive here sits on the same scroll engine that site runs.

    The guarantees

    These hold for every primitive in tier 0, and are asserted by motion.test.tsx:

    1. The server HTML is fully visible. No opacity, transform, or visibility in the markup. Crawlers, no-JS visitors, and the LCP measurement all read the finished page.
    2. Above-the-fold content is never animated in. <Reveal> checks on mount whether its element is already on screen and, if so, does nothing. An opacity: 0 hero cannot be the LCP element; Chrome records LCP at the end of the entrance tween, or never.
    3. A headless renderer gets the content back. Google's Web Rendering Service executes JavaScript but never scrolls. If nothing scrolls within 2.5s of a reveal arming, the content is restored, so the indexed snapshot is never transparent.
    4. Native scroll only. The wheel is never hijacked and the body is never transformed. The liquid feel comes from each scene lerping toward the real scroll position.
    5. prefers-reduced-motion turns it all off. Reveals and parallax stay static; pinned scenes hold one representative frame.
    6. Nothing is mounted by SiteKitLayout. Motion is opt-in per element, never a wrapper around the page.

    Tier 0

    <Reveal>

    Scroll-entrance reveal on a CSS transition. Replaces the per-site Reveal / ScrollReveal / RevealOnScroll components.

    import { Reveal } from '@sonordev/site-kit/motion'
    
    <Reveal>
      <h2>Server-rendered, visible in the HTML, revealed as it scrolls in.</h2>
    </Reveal>
    
    <Reveal as="ul" stagger from="left" distance={32}>
      <li>…</li><li>…</li><li>…</li>
    </Reveal>
    PropDefaultMeaning
    as"div"Element to render
    from"up""up" | "down" | "left" | "right" | "none" — where it travels in from
    distance24Travel in px
    delay0Seconds before the transition starts
    duration0.7Seconds
    staggerfalsetrue (0.08s) or seconds between direct children
    oncetruefalse re-hides when it scrolls back out
    threshold0.15Reveal when the top crosses this far up from the bottom edge (0.15 = 85% down the screen)
    scaleoffStarting scale, e.g. 0.96
    opacity0Starting opacity
    easingease-out quintAny CSS easing

    The element carries data-sk-reveal so you can style or debug it.

    <Parallax> / useParallax(ref, options)

    Scroll-linked transform and opacity, compositor-only, driven by the element's progress through the viewport (0 as its top enters at the bottom, 1 as its bottom leaves at the top). Replaces ParallaxImage / ParallaxY.

    import { Parallax } from '@sonordev/site-kit/motion'
    
    // A parallax photo: the frame clips, the scale hides the travel.
    <div className="overflow-hidden rounded-2xl">
      <Parallax y={[-40, 40]} scale={[1.15, 1.15]}>
        <Image src={photo} alt="…" fill sizes="…" />
      </Parallax>
    </div>

    Options: y (default [40, -40]), x, scale, opacity, rotate — each a [at p=0, at p=1] pair — ease(p) to remap progress, and anchor.

    Above the fold, use anchor="load". Progress runs over the element's whole trip through the viewport, and a hero layer is already partway along that trip when the page loads (p ≈ 0.5), so an unanchored layer jumps to that frame on hydration. A hero photo is usually the LCP element, so it must not move. Anchored, progress counts from where the page was when the layer armed: the first frame renders each range's first value, and from there it moves at the usual rate (one range per full trip). Give it ranges that start at rest:

    // Server component: the photo layer behind the hero copy.
    <section className="relative overflow-hidden">
      <Parallax anchor="load" y={[0, 120]} className="absolute inset-0 -bottom-16">
        <img src="/hero.avif" alt="…" fetchPriority="high" className="h-full w-full object-cover" />
      </Parallax>
      <h1 className="relative">…</h1>
    </section>

    A layer that arms below the fold anchors at 0, so anchor changes nothing for it.

    <ScrollScene> / useScrollScene(ref, onProgress?, options)

    A pinned scene: a tall wrapper with a sticky, viewport-sized stage inside it. As the visitor scrolls the wrapper's height the stage stays put and progress runs 0→1. Progress is written to --sk-p on the wrapper every frame, so choreography can be pure CSS.

    import { ScrollScene } from '@sonordev/site-kit/motion'
    
    <ScrollScene length="300vh" className="scene">
      <h2 className="scene-title">This copy is in the server HTML.</h2>
      <img className="scene-art" src="…" alt="" />
    </ScrollScene>
    .scene-title { opacity: calc(1 - var(--sk-p) * 2); }
    .scene-art   { transform: translateX(calc(var(--sk-p) * -40vw)) scale(calc(1 + var(--sk-p) * 0.4)); }

    onProgress(p, { target, visible }) runs the same frame for canvas frame-scrub, three.js, or anything measured. continuous keeps rendering while visible (shader time), reducedP sets the frame held under reduced motion (default 0.55), lerp tunes the smoothing (default 0.16, 1 = locked to the scrollbar).

    The children are server-rendered. A "use client" component still renders children that arrive as props on the server, so the copy inside a scene is in the HTML. What you must not do is load a component containing a scene through dynamic(…, { ssr: false }): that de-opts the whole subtree to client rendering and the copy disappears from the HTML.

    The engine

    import { registerScene, refreshScenes, prefersReducedMotion, sceneProgress, onNoScroll } from '@sonordev/site-kit/motion'
    
    const stop = registerScene(pinElement, (p, { target, visible }) => draw(p), { mode: 'pin' })

    One shared requestAnimationFrame drives every registered scene and sleeps when everything has settled; scenes beyond a 35% viewport margin are not rendered. Call refreshScenes() after a layout change the engine cannot see (an accordion opening above a scene). sceneProgress is the pure progress math, exported for tests and reuse.

    anchor: 'load' works here too, for a custom scene above the fold: the callback's first p is 0 and it moves one unit per full trip from there (negative if the page scrolls back above where it armed). One scene can drive many layers at their own rates:

    registerScene(
      collage,
      (p) => tiles.forEach((t) => (t.style.transform = `translate3d(0, ${p * Number(t.dataset.depth)}px, 0)`)),
      { mode: 'view', lerp: 1, anchor: 'load' },
    )

    Tier 1 — GSAP

    npm i gsap
    import { useGsap } from '@sonordev/site-kit/motion/gsap'
    
    const ref = useRef<HTMLDivElement>(null)
    useGsap(ref, ({ gsap, el }) => {
      gsap.from(el.querySelectorAll('[data-line]'), {
        yPercent: 100, opacity: 0, stagger: 0.06, duration: 0.8, ease: 'power3.out',
        scrollTrigger: { trigger: el, start: 'top 80%' },
      })
    })

    useGsap loads gsap + ScrollTrigger once per page at idle (whenIdle: after the load event, when the main thread goes quiet), so it's off the LCP path and out of hydration's way but already in hand when a visitor scrolls to the section. It runs the setup when the element comes within 200px of the viewport (near), inside gsap.context(el) so selectors are scoped and everything is reverted on unmount, and skips under reduced motion (reducedMotion: 'run' to opt out). A reduced-motion visitor never downloads gsap at all.

    Plugins beyond ScrollTrigger go in options.plugins, never an import() inside setup:

    useGsap(
      ref,
      ({ gsap, el, plugins: { SplitText } }) => {
        SplitText.create(el, {
          type: 'lines',
          mask: 'lines',
          autoSplit: true,
          onSplit: (self) =>
            gsap.from(self.lines, { yPercent: 115, stagger: 0.09, scrollTrigger: { trigger: el, once: true } }),
        })
      },
      [],
      { plugins: { SplitText: () => import('gsap/SplitText') } },
    )

    They load with gsap (once per page, however many components ask), get registered, and reach setup by name, so setup stays synchronous. That's the point: gsap.context only records what runs synchronously inside it, so a plugin imported from within setup did its work after the context closed. Its tweens and splits were unscoped and never reverted on unmount.

    Anything setup still has to start later (after an await, in a timer) goes through the context it's handed: context.add(() => gsap.to(...)).

    Rules: below the fold only; hide things inside the setup with gsap.set, never with a stylesheet; no ScrollSmoother (it transforms the page body, fights native scroll, and costs INP).

    scrollIn(gsap, el, build, { start })

    A below-the-fold entrance with the rules every kit reveal keeps, for use inside a useGsap setup:

    useGsap(ref, ({ gsap, el }) =>
      scrollIn(gsap, el, (tl) => tl.from(el.querySelectorAll('li'), { y: 24, opacity: 0, stagger: 0.06 })),
    )
    • An element already on screen when setup runs is left as the server rendered it, and build never runs.
    • Otherwise build fills a paused timeline. Its from-states apply at once, while the element is still offscreen, and it plays when the element's top crosses start (default "top 90%").
    • A visitor who never scrolls gets the finished state after 2.5s, callbacks included, through the same failsafe <Reveal> uses.

    It returns the failsafe's cancel; return it from setup as the cleanup.

    <CountUp>

    A stat that rolls up to its value as it scrolls into view. Pass the formatted value as its text; it rolls the first number and keeps everything around it (currency, %, units, decimals, thousands separators only if the text had them):

    import { CountUp } from '@sonordev/site-kit/motion/gsap'
    
    <CountUp as="p" className="stat">$412,500</CountUp>
    <CountUp>{`${ownerPct}%`}</CountUp>

    The server HTML is the real text. A stat already on screen keeps its number, the real text comes back at the end of the roll and from the no-scroll failsafe, and reduced motion never touches it. Props: as (default "span"), className, id, duration (1.3), ease ("power2.out"), start ("top 90%"). parseCountUp(text) is the formatting split, exported for tests.

    Also exported: loadGsap() (the shared loader, immediate), whenIdle() (the idle gate useGsap loads behind), and useExpandCollapse(isOpen) (height/opacity expand without unmounting, so the content stays indexable).

    Tier 2 — three.js

    npm i three && npm i -D @types/three

    The scene component is loaded lazily and mounted as a childless sibling of the server-rendered stage, never wrapping it. Copy lives in the DOM, never in the canvas.

    // page.tsx (server component)
    import dynamic from 'next/dynamic'
    import { ScrollScene } from '@sonordev/site-kit/motion'
    const Scene = dynamic(() => import('./Scene'), { ssr: false })
    
    <ScrollScene id="hero-scene" length="400vh">
      <div className="relative z-10">
        <h1>Real copy, in the HTML, above the canvas.</h1>
      </div>
    </ScrollScene>
    <Scene pinId="hero-scene" />
    // Scene.tsx
    'use client'
    import { useRef, useEffect } from 'react'
    import { useThreeStage } from '@sonordev/site-kit/motion/three'
    import { Mesh, BoxGeometry, MeshStandardMaterial, DirectionalLight } from 'three'
    
    export default function Scene({ pinId }: { pinId: string }) {
      const pin = useRef<HTMLElement | null>(null)
      useEffect(() => { pin.current = document.getElementById(pinId) }, [pinId])
      useThreeStage(pin, {
        onReady: ({ scene, camera }) => {
          camera.position.z = 5
          scene.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial()), new DirectionalLight())
        },
        render: (p, { renderer, scene, camera }) => {
          camera.position.z = 5 - p * 4
          renderer.render(scene, camera)
        },
      })
      return null
    }

    useThreeStage creates the canvas behind the stage's content, sizes it with a ResizeObserver, caps DPR at 1.75 (maxDpr), pauses on context loss, and disposes everything on unmount. canRunWebGL() is false under reduced motion, Save-Data, or without WebGL, in which case nothing mounts and the visitor keeps the server-rendered still. loadTexture(url) resolves null instead of throwing, so one bad asset can't take a scene down. disposeObject(root) frees a subtree.

    Budgets: three itself is ~180KB gzipped, so this tier is for flagship builds; keep models and textures to ≤5MB per scene and fetch them on approach.

    Migrating a site's own Reveal

    Per-site components map onto <Reveal> almost one to one:

    Site prop<Reveal>
    from="up", direction="up"from="up"
    y={30}distance={30}
    stagger (boolean)stagger
    delay, durationsame, in seconds
    threshold, rootMarginthreshold
    onceonce

    Remove @gsap/react and any useGSAP at module scope while you're there: that pattern puts gsap in the initial bundle of every page.

    A site's own count-up becomes <CountUp> with the formatted value as its text (<CountUp value={n} prefix="$" /> → <CountUp>{usd(n)}</CountUp>). A hero or above-the-fold parallax that captured its first progress by hand to stop the hydration jump becomes <Parallax anchor="load">, or registerScene(..., { anchor: 'load' }) for a custom scene.

    Before you ship

    • curl the built page and confirm the copy inside every scene and reveal is in the raw HTML.
    • Mobile Lighthouse ≥ 90: npx lighthouse <url> --form-factor=mobile --only-categories=performance.
    • Toggle reduced motion and confirm the page reads as a plain column.
    • Disable JavaScript and confirm nothing is hidden.