Threshold-based event detection for Motion MotionValue animations.
Subscribe once. Get onEnter / onExit callbacks when a value crosses named thresholds. Zero per-frame overhead after setup.
npm install motion-cuesmotion ^12.0.0 required. React ^19.0.0 optional (for /react).
progress.on("change", (v) => {
if (v > 0.5 && !wasHalf) { /* entered half */ }
if (v < 0.5 && wasHalf) { /* exited half */ }
wasHalf = v > 0.5;
});One threshold, fine. Three thresholds plus bidirectional travel? You're managing sorted arrays, direction flags, deduplication, cleanup — every time.
motion-cues does all of that for you.
| Manual | motion-cues |
|
|---|---|---|
| Threshold bookkeeping | Flags per threshold | Declarative defineCue |
| Direction detection | Manual ascending/descending | Automatic |
| Callback noise | Every frame (~36 per 600ms) | Crossings only (4–8) |
| Listeners | One per concern | One subscription, any cues |
| Bundle | 0 | ~1.2 kB gzipped |
import { useMotionValue } from "motion/react";
import { defineCue, observeCue } from "motion-cues";
const FLOOD = defineCue("flood", { half: 0.5, full: 1 });
const progress = useMotionValue(0);
const stop = observeCue(progress, FLOOD, {
onEnter: (key) => console.log(`entered ${key}`),
onExit: (key) => console.log(`exited ${key}`),
});
stop(); // cleanupimport { useCueState } from "motion-cues/react";
function Progress() {
const progress = useMotionValue(0);
const flags = useCueState(progress, FLOOD);
return <>{flags.half && <span>halfway</span>}</>;
}Concurrent-safe (useSyncExternalStore), no useEffect, re-renders only on flips.
const WAVE = defineCue("wave", { item0: 0.1, item1: 0.4, item2: 0.7 });
observeCue(progress, WAVE, {
onEnter: (key) => {
const i = Number(key.replace("item", ""));
setVisible((v) => v.map((s, j) => (j === i ? true : s)));
},
});const SCROLL = defineCue("scroll", {
headerHidden: 200,
backToTopVisible: 600,
}, { range: [0, 1200] });
const OVERLAY = defineCue("overlay", {
visible: 15, full: 0,
}, { range: [100, 0] });const bus = createCueBus();
observeCue(progress, FLOOD, { bus });
bus.subscribe(() => {
if (bus.has("flood", "half")) track("halfway");
if (bus.has("flood", "full")) startNextPhase();
});const flags = useCueState(overlayX, INTERACTION);
const variant = flags.commit ? "commit" : flags.peek ? "peek" : "idle";
return <button className={variant}>Email Me</button>;const CUE = defineCue("my-cue", { first: 0.25, second: 0.5, third: 0.75 });
const SCROLL = defineCue("scroll", { visible: 200 }, { range: [0, 1000] });Returns stop().
observeCue(motionValue, CUE, {
initialSync: true,
emitExitsOnStop: false,
bus: myBus,
onEnter: (key, ctx) => {},
onExit: (key, ctx) => {},
});ctx: value, progress (0–1), wasActive, direction ("ascending"|"descending"|"none"), velocity.
const bus = createCueBus();
bus.has("cue-name", "threshold");
bus.get("cue-name");
bus.reset("cue-name");
bus.subscribe(() => {});const flags = useCueState(motionValue, CUE);
// { first: boolean, second: boolean, ... }MIT