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.
| Tier | Import | Cost (gzipped) | When |
|---|---|---|---|
| 0 | @sonordev/site-kit/motion | ~2KB, zero deps | Every site |
| 1 | @sonordev/site-kit/motion/gsap | +46KB (gsap + ScrollTrigger), at idle | Sequenced timelines, SplitText, Flip |
| 2 | @sonordev/site-kit/motion/three | +180KB (three), on approach | Flagship 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:
- 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.
- Above-the-fold content is never animated in.
<Reveal>checks on mount whether its element is already on screen and, if so, does nothing. Anopacity: 0hero cannot be the LCP element; Chrome records LCP at the end of the entrance tween, or never. - 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.
- 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.
prefers-reduced-motionturns it all off. Reveals and parallax stay static; pinned scenes hold one representative frame.- 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>| Prop | Default | Meaning |
|---|---|---|
as | "div" | Element to render |
from | "up" | "up" | "down" | "left" | "right" | "none" — where it travels in from |
distance | 24 | Travel in px |
delay | 0 | Seconds before the transition starts |
duration | 0.7 | Seconds |
stagger | false | true (0.08s) or seconds between direct children |
once | true | false re-hides when it scrolls back out |
threshold | 0.15 | Reveal when the top crosses this far up from the bottom edge (0.15 = 85% down the screen) |
scale | off | Starting scale, e.g. 0.96 |
opacity | 0 | Starting opacity |
easing | ease-out quint | Any 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 gsapimport { 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
buildnever runs. - Otherwise
buildfills a paused timeline. Its from-states apply at once, while the element is still offscreen, and it plays when the element's top crossesstart(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/threeThe 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, duration | same, in seconds |
threshold, rootMargin | threshold |
once | once |
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
curlthe 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.