motion-cues
v0.1.0
Published
Threshold cues on a `MotionValue` progress axis — callback API, no React.
Readme
motion-cues
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 is a peer dependency. React (^19.0.0) is optional — only needed for /react.
Why This Exists
The Problem
When animating with Motion, you often need to trigger side effects at specific points along an animation — show an element when progress passes 50%, fire an accessibility announcement when a transition completes, stagger children based on how far along a drive value has traveled.
The naive approach uses Motion's .on("change", ...) listener:
progress.on("change", (v) => {
if (v > 0.5 && !wasHalf) {
// entered half
}
if (v < 0.5 && wasHalf) {
// exited half
}
wasHalf = v > 0.5;
});This works for one threshold. With three or four thresholds, per-element stagger, and bidirectional travel (open/close), the bookkeeping explodes. You need sorted thresholds, direction detection, deduplication, and cleanup logic — every time.
Why Not Just Use useEffect or watch?
useEffect/useMemowith dependency arrays — these fire on every render, not on animation frames. They can't observeMotionValuechanges without bridging through state, which defeats the purpose of Motion's direct DOM updates.motionValue.on("change", ...)directly — fires every frame. If you only care about threshold crossings, 95% of those callbacks are wasted work. You also need to track previous state, handle direction, and manage cleanup manually.useTransform+useMotionValueEvent— closer, but still per-frame. You'd need to build the threshold logic yourself.
What motion-cues Does
- One subscription —
observeCueregisters a single.on("change", ...)listener internally. - Sorted thresholds — thresholds are sorted once at creation. The hot path walks a pre-sorted array matching travel direction. Zero allocations per frame.
- Only fires on crossings —
onEnterfires once when progress crosses a threshold upward.onExitfires once when it crosses downward. No per-frame noise. - Clean API —
defineCuedeclares thresholds by name.observeCuesubscribes.stop()cleans up.
Comparison
| Approach | Per-frame callbacks | Threshold bookkeeping | Direction awareness | Cleanup | Bundle cost |
|----------|:---:|:---:|:---:|:---:|:---:|
| motionValue.on("change", ...) | Every frame | Manual (per threshold) | Manual | Manual | 0 |
| useEffect + state bridge | Every render | Manual | No | Automatic | 0 |
| useTransform + useMotionValueEvent | Every frame | Manual | Manual | Manual | ~1 kB |
| watch (Vue) / signal effects | Every change | Manual | No | Automatic | Varies |
| motion-cues | Only on crossings | Declarative (defineCue) | Automatic | stop() | ~1.2 kB |
When to Use Each
| Scenario | Recommended | Why |
|----------|-------------|-----|
| Read current value once | motionValue.get() | No subscription needed |
| Drive another MotionValue | useTransform | Built for value-to-value mapping |
| Trigger side effects at thresholds | motion-cues | Fires only on crossings, not every frame |
| Animate DOM directly every frame | motionValue.on("change", ...) | You need every intermediate value |
| Conditional rendering based on animation progress | useCueState | Re-renders only when a threshold flips |
| Multiple consumers of the same thresholds | motion-cues + createCueBus | One observer, many subscribers |
Performance
Allocation Profile
| Phase | Allocations |
|-------|-------------|
| defineCue | 1 object (the cue definition) |
| observeCue setup | 2 sorted arrays (ascending + descending) + 1 flags object |
| Per-frame (hot path) | 0 — reuses pre-sorted arrays, no object creation |
| Threshold crossing | 1 context object (passed to callback) |
| stop() | 0 (unless emitExitsOnStop: true, then 1 context per active cue) |
Why Zero Per-Frame Allocation Matters
A 60fps animation fires .on("change", ...) ~60 times per second. Over a 600ms transition, that's ~36 callbacks. If your handler creates objects, compares state, or branches on multiple thresholds, you're allocating and garbage-collecting on every frame — which can cause jank on low-end devices.
motion-cues front-loads all work:
defineCue("wave", { a: 0.1, b: 0.4, c: 0.7 })
→ sorts thresholds once: [a, b, c] (ascending) / [c, b, a] (descending)
observeCue(progress, wave, { onEnter, onExit })
→ registers ONE .on("change", ...) listener
→ per frame: compare value against pre-sorted array, skip if unchanged
→ fires callback ONLY when a threshold is crossedBenchmark (Conceptual)
For a cue with 4 thresholds over a 600ms animation at 60fps:
| Metric | on("change", ...) | motion-cues |
|--------|---------------------|---------------|
| Callbacks fired | ~36 | 4–8 (only crossings) |
| Objects allocated | 36+ (per handler) | 4–8 (context objects) |
| Threshold comparisons | 144 (4 thresholds × 36 frames) | 36 (early exit on sorted walk) |
The difference is small for one cue. It compounds when you have multiple cues driving multiple elements — the stagger pattern in the examples section uses one cue with 3 thresholds across many children. With motion-cues, that's still one subscription.
Quickstart
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}`),
});
// progress.set(0.6) → logs "entered half"
// progress.set(1.0) → logs "entered full"
// progress.set(0.3) → logs "exited full", "exited half"
stop(); // cleanupReact Usage
import { useMotionValue } from "motion/react";
import { defineCue } from "motion-cues";
import { useCueState } from "motion-cues/react";
const FLOOD = defineCue("flood", { half: 0.5, full: 1 });
function Progress() {
const progress = useMotionValue(0);
const flags = useCueState(progress, FLOOD);
return (
<>
{flags.half && <span>halfway</span>}
{flags.full && <span>done</span>}
</>
);
}useCueState is backed by useSyncExternalStore — concurrent-safe, no useEffect, re-renders only when a threshold flips.
Examples
Staggered Children
Drive multiple children from a single progress value without per-key delays:
import { useMotionValue, animate } from "motion/react";
import { defineCue, observeCue } from "motion-cues";
const WAVE = defineCue("wave", { item0: 0.1, item1: 0.4, item2: 0.7 });
function StaggeredList() {
const progress = useMotionValue(0);
const [visible, setVisible] = useState([false, false, false]);
useEffect(() =>
observeCue(progress, WAVE, {
onEnter: (key) => {
const i = Number(key.replace("item", ""));
setVisible((v) => v.map((s, j) => (j === i ? true : s)));
},
}),
[]);
// Animate progress once — children appear at their thresholds
const open = () => animate(progress, 1, { duration: 0.6 });
}Accessibility Announcements
Announce state changes to screen readers at the right moment:
import { announcePolite } from "./a11y";
import { defineCue, observeCue } from "motion-cues";
const TRANSITION = defineCue("transition", {
started: 0.05,
complete: 0.95,
});
observeCue(progress, TRANSITION, {
onEnter: (key) => {
if (key === "complete") {
announcePolite("Content loaded");
}
},
});Custom Ranges
Cues work with any numeric range, not just [0, 1]:
// Scroll position in pixels
const SCROLL = defineCue("scroll", {
headerHidden: 200,
backToTopVisible: 600,
}, { range: [0, 1200] });
// Overlay position (100 → 0)
const OVERLAY = defineCue("overlay", {
visible: 15,
full: 0,
}, { range: [100, 0] });Bus Fan-Out
Share one observer across multiple listeners:
import { createCueBus, defineCue, observeCue } from "motion-cues";
const bus = createCueBus();
const FLOOD = defineCue("flood", { half: 0.5, full: 1 });
// One observer, many subscribers
observeCue(progress, FLOOD, { bus });
// Subscriber A — drives animation
bus.subscribe(() => {
if (bus.has("flood", "full")) startNextPhase();
});
// Subscriber B — drives analytics
bus.subscribe(() => {
if (bus.has("flood", "half")) track("halfway");
});Conditional UI Branching
Show different UI based on how far along an animation has progressed:
const INTERACTION = defineCue("interaction", {
peek: 0.15,
commit: 0.85,
}, { range: [100, 0] });
function EmailButton() {
const overlayX = useMotionValue(100);
const flags = useCueState(overlayX, INTERACTION);
const variant = flags.commit ? "commit" : flags.peek ? "peek" : "idle";
return <button className={variant}>Email Me</button>;
}API
defineCue(name, schema, options?)
Create a named cue with threshold values.
const CUE = defineCue("my-cue", {
first: 0.25,
second: 0.5,
third: 0.75,
});
// Custom range (default is [0, 1])
const SCROLL = defineCue("scroll", { visible: 200 }, { range: [0, 1000] });observeCue(source, cue, options?)
Subscribe to threshold crossings. Returns stop().
const stop = observeCue(motionValue, CUE, {
initialSync: true, // fire onEnter for already-active thresholds (default: true)
emitExitsOnStop: false, // fire onExit for all active cues when stop() is called
bus: myBus, // optional fan-out bus
onEnter: (key, ctx) => { /* threshold crossed upward */ },
onExit: (key, ctx) => { /* threshold crossed downward */ },
});
stop(); // unsubscribeThe ctx object provides: value, progress (normalized 0-1), wasActive, direction ("ascending" | "descending" | "none"), and velocity.
createCueBus()
Shared store for fan-out. Pass bus to observeCue, then subscribe.
const bus = createCueBus();
bus.has("cue-name", "threshold"); // boolean
bus.get("cue-name"); // { triggered: Set<string> }
bus.reset("cue-name"); // clear all triggered keys
bus.subscribe(() => { ... }); // returns unsubscribe fnuseCueState(source, cue, options?)
React hook. Returns Record<K, boolean> of active thresholds.
const flags = useCueState(motionValue, CUE);
// flags.first → boolean
// flags.second → booleanBacked by useSyncExternalStore. No useEffect. Re-renders only on threshold flips.
Compatibility
| Package | Range |
| -------- | --------- |
| motion | ^12.0.0 |
React (^19.0.0) is an optional peer — only needed for /react.
Versioning
Pre-1.0: minor bumps may break; patches are additive/fixes.
License
MIT
