Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Motion — things that move when you scroll, tap and wait

This is the same document the Vibes code generator reads when it builds apps — rendered from 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 reveal per part with amount: 0.5, and a scrollProgress on 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 — countUp on each stat as its card reveals: call countUp inside the inView callback rather than on mount.
  • A choreographed intro — one sequence with at offsets, under two seconds, that lands every element where it already sat.

Gotchas

  • The hooks break after adding motion — motion/react or framer-motion brought a second React. The vanilla entry carries none.
  • The page is blank until the engine loads — above-the-fold elements started hidden. reveal skips what is on screen; sequences start from the element's visible state a moment after paint.
  • animate-in, fade-in-0 and their siblings compile to nothing — they are plugin classes the runtime's Tailwind does not load. Motion through refs, or the starting: classes for a list entry.
  • An inline style={{ animationDelay }} in the JSX — the house keeps JSX class-only. A delay is stagger on a helper, or a delay-* class on a starting: 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 animate call 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 click handler animates beside press. One gesture helper per control.