Motion — things that move when you scroll, tap and wait
prompts/pkg/llms/motion.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for a page that tells a story as you scroll, a launch or
landing page whose sections reveal as they arrive, a bar that fills with how
far you have read, a number that counts up, a headline that arrives in a
choreographed sequence, cards that spring in, buttons that squash when
pressed, a parallax hero, or any ask to make a page "feel alive" with motion,
this is the recipe. Motion (motion.dev, MIT) is the engine: its vanilla
animate, scroll, inView, stagger, press and hover functions
animate real DOM elements through refs, and the app keeps a small helper
module around them that handles the three things every animated page owes
its reader: it arrives after first paint, it respects reduced motion, and it
stops when the component goes away.
The sibling boundary: a list whose items fade and rise as they enter uses
the house flourish, the starting: Tailwind classes, with no engine at all;
a canvas that redraws every frame is p5, worlds or physics; pouf
components carry their own motion (a Card with motion="lift", the
toasts, the sheets). Motion is the right call when something on the page
responds to scroll, to time, or to touch.
The worked examples below were built and measured for this doc (Chromium,
2026-10-07, [email protected] from esm.sh): a launch page told by scrolling,
and a kudos board whose notes spring in and squash when pressed.
Project shape
App.jsx the page: refs on the moving parts, one effect that sets motion up and returns its cleanup
lib/motion.js the loader and the helpers: reveal, staggerIn, scrollProgress, countUp, sequence, move, pressSquash
access.js who may post to a shared board (when the app has one)
The engine arrives at first use
Import the engine with its full pinned esm.sh URL inside a memoized
module-level loader, and let every helper await it. The import is a
body-position await import("https://esm.sh/[email protected]"): the platform
rewrites bare specifiers only in the top-of-file import region, so a lazy
import carries the complete versioned URL. Laziness is the point: first paint
and the server render owe nothing to the engine (measured: about 1.4 s to
arrive cold), the page is readable before it lands, and the helpers attach
motion to a page that already exists. The vanilla entry is the one to use;
it carries no React (measured: the module's motion export is undefined),
so the app's one React instance stays the only one and hooks keep working.
motion/react and framer-motion are a second React waiting to happen in
this runtime; the vanilla functions do everything a page needs.
Every helper returns a stop function at once, before the engine has
arrived, so an effect can collect them and return one cleanup. A stop called
before the engine lands cancels the setup (measured: a move stopped during
the load moved nothing). motionScope() is the collector.
Every helper reads the reduced-motion setting when it runs and lands on the end state instead of animating. Pouf's own reduced-motion rule is scoped to pouf's elements, so an animation the app authors gets no safety net from it; the helpers are the net (measured with the media feature emulated: lists land visible, reveals leave everything visible, the count shows its final number, the press does nothing, scroll progress still reports).
lib/motion.js
js// lib/motion.js — the app's motion helpers on top of Motion's vanilla API.
// The engine arrives by a body-position import of its pinned URL the first time a helper runs,
// so first paint and the server render owe it nothing. Every helper returns a stop function at
// once (before the engine has even loaded), so an effect collects them and returns one cleanup;
// every helper reads the reduced-motion setting when it runs and lands on the end state instead.
const ENGINE_URL = "https://esm.sh/[email protected]";
let enginePromise;
/** One fetch of the engine, however many helpers ask for it. */
export function loadMotion() {
enginePromise ??= import(ENGINE_URL);
return enginePromise;
}
/** True when the person asked their system for less motion. Read at call time, not import time. */
export function prefersReducedMotion() {
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/** Collect stop functions from helpers; `cleanup()` runs them all, from an effect's return. */
export function motionScope() {
const stops = new Set();
return {
add(stop) {
stops.add(stop);
return stop;
},
cleanup() {
for (const stop of stops) stop();
stops.clear();
},
};
}
/** Wrap an async setup: returns a stop at once; the setup's own stop runs when it has resolved. */
function later(setup) {
let stopped = false;
let stopInner = null;
loadMotion().then((m) => {
if (stopped) return;
stopInner = setup(m) || null;
});
return () => {
if (stopped) return; // a stop called twice runs the inner stop once
stopped = true;
const stop = stopInner;
stopInner = null;
if (stop) stop();
};
}
const elementsOf = (target) => (typeof target === "string" ? [...document.querySelectorAll(target)] : [target].flat());
/**
* Move elements to `keyframes`; under reduced motion, land there at once. Returns a stop that
* cancels the animation (the element returns to where it was).
*/
export function move(target, keyframes, options = {}) {
return later((m) => {
const opts = prefersReducedMotion() ? { ...options, duration: 0, delay: 0 } : options;
const controls = m.animate(target, keyframes, opts);
return () => controls.cancel();
});
}
/** Play a sequence of [target, keyframes, options] segments (`at` sets timing). Same stop contract. */
export function sequence(segments) {
return later((m) => {
if (prefersReducedMotion()) {
for (const [target, keyframes] of segments) m.animate(target, keyframes, { duration: 0 });
return;
}
const controls = m.animate(segments);
return () => controls.cancel();
});
}
/**
* Reveal elements as they enter the viewport, once each. Elements already on screen when this
* runs stay as they are (no flash); the ones below the fold are hidden and rise in when seen.
*/
export function reveal(selector, { y = 16, duration = 0.5, amount = 0.3 } = {}) {
return later((m) => {
if (prefersReducedMotion()) return;
const stops = [];
for (const el of elementsOf(selector)) {
if (el.getBoundingClientRect().top < window.innerHeight) continue;
el.style.opacity = "0";
stops.push(m.inView(el, () => void m.animate(el, { opacity: [0, 1], y: [y, 0] }, { duration, ease: "easeOut" }), { amount }));
}
return () => stops.forEach((stop) => stop());
});
}
/** Stagger a list in: each child rises after the previous. */
export function staggerIn(selector, { gap = 0.06, y = 12, duration = 0.4, from = "first" } = {}) {
return later((m) => {
const opts = prefersReducedMotion() ? { duration: 0 } : { duration, delay: m.stagger(gap, { from }), ease: "easeOut" };
const controls = m.animate(selector, { opacity: [0, 1], y: [y, 0] }, opts);
return () => controls.cancel();
});
}
/**
* Report how far `target` has scrolled through the viewport: `onProgress(p)` with p in 0..1 between
* the offsets (`["start end", "end start"]`: from the target's top meeting the bottom of the
* screen to its bottom leaving the top). Progress is a measurement, so it runs under reduced
* motion too; what the app does with it decides the motion.
*/
export function scrollProgress(target, onProgress, { offset = ["start end", "end start"], container } = {}) {
return later((m) => m.scroll((p) => onProgress(p), { target, offset, container }));
}
/** Count a number up in an element's text, formatted by `format`. Under reduced motion, show the end. */
export function countUp(el, to, { duration = 1.2, format = (n) => Math.round(n).toLocaleString() } = {}) {
return later((m) => {
if (prefersReducedMotion()) {
el.textContent = format(to);
return;
}
const controls = m.animate(0, to, { duration, ease: "easeOut", onUpdate: (n) => (el.textContent = format(n)) });
return () => controls.stop();
});
}
/** Run `fn` once when `target` enters the viewport (for a count-up or a late reveal). Returns the stop. */
export function onEnter(target, fn, { amount = 0.5 } = {}) {
return later((m) =>
m.inView(target, () => {
fn();
}, { amount })
);
}
/** A press squash with a spring back; works for mouse, touch and keyboard through Motion's `press`. */
export function pressSquash(target, { scale = 0.94 } = {}) {
return later((m) =>
m.press(target, (el) => {
if (prefersReducedMotion()) return;
m.animate(el, { scale }, { duration: 0.1 });
return () => m.animate(el, { scale: 1 }, { type: "spring", stiffness: 500, damping: 18 });
})
);
}
A page told by scrolling
App.jsx
jsximport React, { useEffect, useRef } from "react";
import { Button, Card, Heading, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { countUp, motionScope, onEnter, pressSquash, reveal, scrollProgress, sequence, staggerIn } from "./lib/motion.js";
const CHAPTERS = [
{ title: "The idea", body: "A show about the people who fix things nobody else will touch." },
{ title: "The first season", body: "Twelve conversations, recorded in workshops, kitchens and one lighthouse." },
{ title: "What comes next", body: "Season two opens the mailbag. Send us the thing you cannot fix." },
];
export default function App() {
const storyRef = useRef(null);
const barRef = useRef(null);
const listenersRef = useRef(null);
// One effect sets every piece of motion up and returns one cleanup. The engine arrives after
// first paint; the page reads fine before it does and with it stopped.
useEffect(() => {
const scope = motionScope();
// The hero is above the fold, so its sequence moves position and scale a little and leaves
// opacity alone: the headline is readable before, during and after.
scope.add(sequence([
["#headline", { y: [10, 0] }, { duration: 0.5 }],
["#tagline", { y: [6, 0] }, { at: "-0.2", duration: 0.4 }],
["#listen", { scale: [0.97, 1] }, { at: "-0.1", type: "spring", bounce: 0.3 }],
]));
scope.add(reveal("[data-reveal]"));
scope.add(staggerIn("#chapters li", { gap: 0.08 }));
scope.add(scrollProgress(storyRef.current, (p) => {
if (barRef.current) barRef.current.style.transform = `scaleX(${p})`;
}));
// The number sits below the story, so it counts when the reader reaches it, once.
scope.add(onEnter(listenersRef.current, () => scope.add(countUp(listenersRef.current, 48210))));
scope.add(pressSquash("#listen"));
return scope.cleanup;
}, []);
return (
<main className="min-h-screen bg-background text-foreground">
<div className="fixed top-0 left-0 right-0 h-1 bg-muted">
<div ref={barRef} className="h-full w-full bg-primary origin-left" />
</div>
<section className="min-h-screen flex items-center px-6 md:px-12">
<Stack gap={4}>
<Heading id="headline" level={1}>Fix It Anyway</Heading>
<Text id="tagline" muted>A podcast about stubborn problems and the people who outlast them.</Text>
<Row gap={2}>
<Button id="listen" size="lg">Listen to the trailer</Button>
</Row>
</Stack>
</section>
<section ref={storyRef} className="px-6 md:px-12 py-24">
<Stack gap={12}>
{CHAPTERS.map((chapter) => (
<Card key={chapter.title} data-reveal variant="tight">
<Stack gap={2}>
<Heading level={2}>{chapter.title}</Heading>
<Text>{chapter.body}</Text>
</Stack>
</Card>
))}
</Stack>
</section>
<section className="px-6 md:px-12 py-24">
<Row gap={6} align="center" wrap>
<Stack gap={1}>
<Heading level={2}><span ref={listenersRef}>0</span></Heading>
<Text muted>listeners so far</Text>
</Stack>
<Spacer />
<ul id="chapters" className="grid gap-2">
{CHAPTERS.map((chapter) => (
<li key={chapter.title}><Text size="sm">{chapter.title}</Text></li>
))}
</ul>
</Row>
</section>
</main>
);
}
The JSX stays class-only: positions, sizes and colours are Tailwind classes
and pouf props, and Motion writes transforms and opacity onto the elements
through refs and selectors. The one place a style is set in the app's own
code is the progress bar's transform inside the scroll callback, through
its ref, which is what a scroll binding is for.
The three obligations, in order
Visible first. Nothing above the fold starts hidden. reveal leaves any
element already on screen alone and hides only the ones below the fold, so
the page reads before the engine arrives, reads with it stopped, and the
publish screenshot (taken soon after load) shows the hero rather than a
blank. A headline sequence above the fold moves position or scale a little
and leaves opacity alone, so the words are readable before the engine
arrives, during the sequence, and after it lands where the element already
sat; an entrance from opacity zero belongs below the fold, where reveal
hides nothing the reader can see yet. A count-up below the fold starts when
its element enters (onEnter), so the reader sees it count rather than its
end.
Reduced motion is a setting, read when the animation starts. The helpers
call matchMedia("(prefers-reduced-motion: reduce)") inside later, at the
moment the engine is in hand, rather than once at import time, so a person
who toggles the setting while the page is open gets the next animation
right. Under the setting: move, sequence and staggerIn land at once,
reveal leaves everything visible, countUp prints the final number,
pressSquash does nothing, and scrollProgress keeps reporting because a
progress measurement is not motion.
Stop everything in the effect's cleanup. A scroll binding that outlives
its component keeps writing to a detached element; an inView observer
keeps a reference to a node React dropped; a press listener on a button that
re-rendered answers the old one. motionScope collects the stops and the
effect returns scope.cleanup (measured: after cleanup, scrolling reports
nothing and a press squashes nothing).
Scroll
scrollProgress(target, onProgress, { offset, container }) reports 0..1 as
target moves through the viewport. The offsets are Motion's
"target-point container-point" pairs: ["start end", "end start"] runs
from the target's top meeting the screen's bottom to its bottom leaving the
top, so a section drives a bar for exactly as long as it is on screen;
["start start", "end end"] tracks a whole page. Spend the value on
transform and opacity (composited, cheap); Motion rides the browser's
ScrollTimeline where it exists, so a scroll-bound transform stays smooth on
a phone. Measured: a 1500 px section reports 0.53 at mid-scroll and stops
reporting after cleanup.
A scroll-driven animation (an element that rotates or fades with the
scroll) is m.scroll(m.animate(el, { rotate: 180 }), { target }) inside a
helper of the same shape as scrollProgress; the progress form is the
starter because most pages want a number they can spend on several things.
Sequences and springs
sequence([[target, keyframes, options], …]) plays segments in order; at
retimes a segment: "<" starts with the previous one, "+0.5" half a
second after it ends, "-0.2" two tenths before. A spring is an option on
any segment or move: { type: "spring", stiffness: 500, damping: 18 }
for a snap, { type: "spring", bounce: 0.3 } for a soft landing. Keyframe
arrays run through every value ({ x: [0, 40, 0] }), and repeat: Infinity
with repeatType: "reverse" makes a breathing idle; keep an idle to one
small element, since nothing should move while someone is reading.
Gestures
pressSquash(target) is Motion's press: it fires for mouse, touch and the
keyboard, hands back the release through the callback's return, and is the
one gesture most pages need. hover(target, onEnter) has the same shape and
returns a stop. Both are set up in the same effect and stopped in the same
cleanup.
A board whose notes spring in
lib/motion.js
js// The same helper module as the story page; kept once per app.
const ENGINE_URL = "https://esm.sh/[email protected]";
let enginePromise;
export function loadMotion() {
enginePromise ??= import(ENGINE_URL);
return enginePromise;
}
export function prefersReducedMotion() {
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function motionScope() {
const stops = new Set();
return {
add(stop) {
stops.add(stop);
return stop;
},
cleanup() {
for (const stop of stops) stop();
stops.clear();
},
};
}
function later(setup) {
let stopped = false;
let stopInner = null;
loadMotion().then((m) => {
if (stopped) return;
stopInner = setup(m) || null;
});
return () => {
if (stopped) return; // a stop called twice runs the inner stop once
stopped = true;
const stop = stopInner;
stopInner = null;
if (stop) stop();
};
}
export function move(target, keyframes, options = {}) {
return later((m) => {
const opts = prefersReducedMotion() ? { ...options, duration: 0, delay: 0 } : options;
const controls = m.animate(target, keyframes, opts);
return () => controls.cancel();
});
}
export function pressSquash(target, { scale = 0.94 } = {}) {
return later((m) =>
m.press(target, (el) => {
if (prefersReducedMotion()) return;
m.animate(el, { scale }, { duration: 0.1 });
return () => m.animate(el, { scale: 1 }, { type: "spring", stiffness: 500, damping: 18 });
})
);
}
App.jsx
jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { motionScope, move, pressSquash } from "./lib/motion.js";
export default function App() {
const { database, useLiveQuery } = useFireproof("kudos");
const { me, can, ready } = useVibe("kudos");
const { requestLogin, ViewerTag } = useViewer();
const { docs } = useLiveQuery("type", { key: "kudo" });
const kudos = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
const [to, setTo] = useState("");
const [text, setText] = useState("");
const newestRef = useRef(null);
const seenRef = useRef(null); // null until the first populated snapshot, which is the baseline
const newestId = kudos[0]?._id ?? null;
// The send button squashes on press; set up once, stopped on unmount.
useEffect(() => {
const scope = motionScope();
scope.add(pressSquash("#send"));
return scope.cleanup;
}, []);
// A note that was not on the board before springs in; the ones already there stay put. The first
// populated snapshot is the baseline (a loaded board stands still), and the effect keys on the
// newest id so a reorder or an edit elsewhere cancels nothing.
useEffect(() => {
if (!newestId) return;
if (seenRef.current === null) {
seenRef.current = new Set(kudos.map((k) => k._id));
return;
}
if (seenRef.current.has(newestId) || !newestRef.current) return;
const scope = motionScope();
scope.add(move(newestRef.current, { opacity: [0, 1], scale: [0.9, 1], y: [8, 0] }, { type: "spring", bounce: 0.35 }));
return scope.cleanup;
}, [newestId]);
// Every id in every snapshot is remembered, after the effect above has looked.
useEffect(() => {
if (seenRef.current) for (const k of kudos) seenRef.current.add(k._id);
}, [kudos]);
const draft = { type: "kudo", to, text, authorHandle: me?.userHandle };
const canPost = Boolean(ready && me && to.trim() && text.trim() && can.create(draft).ok);
async function send() {
if (!canPost) return;
setTo(""); // ready for the next kudo at once
setText("");
await database.put({ ...draft, _id: crypto.randomUUID(), createdAt: Date.now() });
}
return (
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
<Stack gap={4}>
<Heading level={1}>Kudos</Heading>
{!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to leave a note.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
{ready && me && (
<Card variant="tight">
<Row gap={2} align="top" wrap>
<Field label="To">{(id, d) => <Input id={id} describedBy={d} value={to} onChange={setTo} placeholder="a teammate" />}</Field>
<Field label="For">{(id, d) => <Input id={id} describedBy={d} value={text} onChange={setText} placeholder="the thing they did" />}</Field>
<Button id="send" onClick={send} disabled={!canPost}>Send kudos</Button>
</Row>
</Card>
)}
<div className="grid gap-3 md:grid-cols-2">
{kudos.map((k, i) => (
<div key={k._id} ref={i === 0 ? newestRef : null}>
<Card variant="tight" motion="lift">
<Stack gap={1}>
<Heading level={3}>{k.to}</Heading>
<Text>{k.text}</Text>
<Row gap={2} align="center" wrap>
{k.authorHandle && <ViewerTag userHandle={k.authorHandle} />}
<Spacer />
{can.delete(k).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(k._id)}>Remove</Button>}
</Row>
</Stack>
</Card>
</div>
))}
</div>
</Stack>
</main>
);
}
access.js
js// kudo: a note on the shared board. Its author edits and removes it; everyone reads the board.
export function kudos(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to leave a note" };
if (doc.type === "kudo" || (doc._deleted && oldDoc && oldDoc.type === "kudo")) {
const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
if (author !== user.userHandle) throw { forbidden: "notes are signed as yourself" };
if (oldDoc && !doc._deleted && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a note keeps its author" };
return { channels: ["board"], grant: { public: ["board"] } };
}
throw { forbidden: "unknown document type" };
}
The newest note is the only one that moves: the first populated snapshot
is the baseline and animates nothing, every later snapshot's ids are
remembered, and only a top note nobody has seen springs in, so a board that
loaded with twenty notes stands still and a reorder moves nothing. (A board
that is empty when it opens treats its first note as the baseline too; the
second note is the first to spring.) Pouf's Card motion="lift" carries
the hover, so the app writes none of its own.
Mobile
A press is Motion's press, which answers touch as well as mouse, so a
squash works under a thumb. Scroll-bound values spend on transform and
opacity only, which a phone composites without repainting. A reveal that
waits for amount: 0.3 of the element to be visible feels right on a
narrow screen where sections arrive one at a time; amount: "all" waits too
long. Keep entrances under half a second and idles to one small element,
since the publish screenshot and a reader's eye both land soon after paint.
Shapes a motion vibe takes
- A launch or story page — a headline sequence, sections that reveal, a scroll progress bar, a counting number, a springy call to action. The first example.
- A board or feed with life — the newest item springs in, buttons squash, nothing else moves. The second example.
- A scroll-told explainer — a diagram whose parts arrive as the reader
reaches each paragraph: one
revealper part withamount: 0.5, and ascrollProgresson the whole article driving a position indicator. - A parallax hero —
m.scroll(m.animate(image, { y: 120 }), { target: hero })so the picture drifts slower than the words; keep the drift under a quarter of the hero's height. - A number dashboard —
countUpon each stat as its card reveals: callcountUpinside theinViewcallback rather than on mount. - A choreographed intro — one
sequencewithatoffsets, under two seconds, that lands every element where it already sat.
Gotchas
- The hooks break after adding motion —
motion/reactorframer-motionbrought a second React. The vanilla entry carries none. - The page is blank until the engine loads — above-the-fold elements
started hidden.
revealskips what is on screen; sequences start from the element's visible state a moment after paint. animate-in,fade-in-0and their siblings compile to nothing — they are plugin classes the runtime's Tailwind does not load. Motion through refs, or thestarting:classes for a list entry.- An inline
style={{ animationDelay }}in the JSX — the house keeps JSX class-only. A delay isstaggeron a helper, or adelay-*class on astarting:entry. - Scrolling still moves something after navigating away — a scroll
binding or observer with no stop. Every helper's stop goes into the
scope, and the effect returns
scope.cleanup. - The whole board animates on every change — the effect animated every item. Keep the seen ids and move only the new one.
- Reduced motion still animates — the setting was read once at import,
or the app wrote its own
animatecall beside the helpers. Every animation goes through a helper, and the helpers read the setting when they run. - A press fires twice on a phone — a
clickhandler animates besidepress. One gesture helper per control.