@octane-ts/motion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @octane-ts/motion
2
+
3
+ [Framer Motion](https://motion.dev) for the [octane](https://github.com/octane-ts/octane) renderer.
4
+
5
+ Motion separates a framework-agnostic animation engine (`animate`) and gesture
6
+ primitives (`hover`, `press`) from its React components (`motion.div`,
7
+ `AnimatePresence`). This package reuses the engine + gestures verbatim and
8
+ reimplements the components on octane.
9
+
10
+ ```tsx
11
+ // before
12
+ import { motion, AnimatePresence } from 'motion/react';
13
+ // after
14
+ import { motion, AnimatePresence } from '@octane-ts/motion';
15
+
16
+ function Card() @{
17
+ <motion.div
18
+ className="card"
19
+ initial={{ opacity: 0, y: 20 }}
20
+ animate={{ opacity: 1, y: 0 }}
21
+ transition={{ duration: 0.3 }}
22
+ whileHover={{ scale: 1.05 }}
23
+ whileTap={{ scale: 0.95 }}
24
+ >
25
+ {'hello'}
26
+ </motion.div>
27
+ }
28
+
29
+ function List(props) @{
30
+ <AnimatePresence>
31
+ @if (props.show) {
32
+ <motion.div exit={{ opacity: 0 }}>{'I fade out when removed'}</motion.div>
33
+ }
34
+ </AnimatePresence>
35
+ }
36
+ ```
37
+
38
+ ## What's bound
39
+
40
+ - `motion.<tag>` — `initial`, `animate`, `transition`, `whileHover`, `whileTap`,
41
+ `whileFocus`, `whileInView` (+ `viewport`), `exit`, `drag` (+ `dragConstraints`,
42
+ `onDrag*`), `layout`, `layoutId`, `variants`, plus any DOM props (className, style,
43
+ events, …) and `style` MotionValues spread/bound onto the element.
44
+ - `AnimatePresence` — exit animations on removal.
45
+ - `MotionConfig` — global `transition` / `reducedMotion` defaults via context.
46
+ - `variants` — label resolution (`animate="visible"`) + parent→child propagation +
47
+ `staggerChildren` / `delayChildren` (number or `stagger()` function) / `staggerDirection`.
48
+ - `useMotionValue()`, `useScroll()`, `useAnimate()` — MotionValues, scroll-linked
49
+ values, and imperative scoped animation.
50
+ - `useTransform()`, `useSpring()`, `useMotionValueEvent()` — MotionValue composition:
51
+ derive a value (range-map / transformer / multi-input combiner), spring toward a
52
+ value or source, and subscribe to a value's events.
53
+ - Motion's framework-agnostic helpers (`animate`, `stagger`, value types, …),
54
+ re-exported.
55
+
56
+ ## How it works
57
+
58
+ octane had no public way for a runtime-proxy component to render a host element
59
+ wrapping children, nor to provide context from plain-TS — so this package added two
60
+ runtime primitives: `hostComponent` and `provideContext`. `motion.<tag>` renders a
61
+ real `<tag>` through `hostComponent`, captures the node, and drives:
62
+
63
+ - **Animations** from layout effects calling motion's `animate()`; **gestures** via
64
+ `hover()` / `press()` / `inView()`; **MotionValues** (from `useMotionValue` /
65
+ `useScroll`) by subscribing in `style` and writing the element directly.
66
+ - **`MotionConfig` + `variants`** through `provideContext`: a plain-TS component
67
+ stamps context for its children (config defaults, active variant labels).
68
+ - **`drag`** with pointer events (axis lock + `dragConstraints`).
69
+ - **Exit** without any deferred-deletion machinery: octane fires cleanups *before*
70
+ detaching the DOM, so a leaving element's unmount cleanup clones it (outside the
71
+ range octane is about to remove), animates the exit on the clone, and removes it
72
+ when it finishes.
73
+ - **`layout` / `layoutId`** via FLIP: measure the box, and if it moved/resized —
74
+ vs the previous commit (`layout`) or a same-id element that just unmounted
75
+ (`layoutId`) — apply the inverse transform then animate it back to identity. The
76
+ same cleanup-before-detach ordering lets a leaving `layoutId` element record its
77
+ box for the next one.
78
+
79
+ ## Not yet ported
80
+
81
+ The full layout **projection tree** — nested projection, child scale correction, and
82
+ continuous shared-layout during drag (the `layout`/`layoutId` here are single-element
83
+ FLIPs). Also drag momentum/elastic physics, reduced-motion enforcement, and
84
+ `useTransform`'s output-map form (`useTransform(mv, [0, 100], { opacity: [0, 1] })`).
85
+
86
+ Stagger specifics: `when: 'beforeChildren' | 'afterChildren'` parent/child sequencing
87
+ is not implemented, and a child's stagger index is fixed at registration order (a
88
+ keyed reorder does not re-stagger).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@octane-ts/motion",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Framer Motion bindings for the octane renderer — reuses motion-dom's animation engine, swaps the React components for octane host components.",
7
+ "author": {
8
+ "name": "Dominic Gannaway",
9
+ "email": "dg@domgan.com"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octane-ts/octane.git",
17
+ "directory": "packages/motion"
18
+ },
19
+ "main": "src/index.ts",
20
+ "module": "src/index.ts",
21
+ "types": "src/index.ts",
22
+ "files": [
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/index.ts"
28
+ },
29
+ "dependencies": {
30
+ "motion": "^12.0.0",
31
+ "octane-ts": "0.1.1"
32
+ },
33
+ "devDependencies": {
34
+ "vitest": "^4.1.9"
35
+ },
36
+ "scripts": {
37
+ "test": "vitest run"
38
+ }
39
+ }
package/src/context.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Contexts shared across motion components.
2
+ import { createContext, useContext } from 'octane-ts';
3
+
4
+ // MotionConfig — global defaults (transition, reduced motion) inherited by every
5
+ // motion element below a `<MotionConfig>`.
6
+ export interface MotionConfigValue {
7
+ transition?: any;
8
+ reducedMotion?: 'always' | 'never' | 'user';
9
+ }
10
+ export const MotionConfigContext = createContext<MotionConfigValue>({});
11
+
12
+ // Variant labels propagated from a parent motion element to its descendants, so a
13
+ // child with `variants` but no explicit `animate` inherits the parent's active
14
+ // label (Framer Motion's variant propagation).
15
+ export interface VariantLabels {
16
+ initial?: string;
17
+ animate?: string;
18
+ }
19
+ export const VariantContext = createContext<VariantLabels>({});
20
+
21
+ // Stagger orchestration: a motion element with `staggerChildren`/`delayChildren` in
22
+ // its (variant) transition provides this to its variant-inheriting children, who each
23
+ // register to get a stable index and derive a per-child animation delay from it.
24
+ export interface StaggerOrchestration {
25
+ active: boolean;
26
+ staggerChildren: number;
27
+ // A number base delay, or Framer's `stagger()` / `(index, total) => delay` function.
28
+ delayChildren: number | ((index: number, total: number) => number);
29
+ staggerDirection: number;
30
+ // Ordered child tokens (registered in render/DOM order); index + length drive delay.
31
+ children: any[];
32
+ }
33
+ export const StaggerContext = createContext<StaggerOrchestration | null>(null);
34
+
35
+ export function useMotionConfig(): MotionConfigValue {
36
+ return useContext(MotionConfigContext);
37
+ }
38
+
39
+ export function useInheritedVariants(): VariantLabels {
40
+ return useContext(VariantContext);
41
+ }
42
+
43
+ // Resolve a target that may be a variant label (string) against a `variants` map,
44
+ // or pass an inline target object straight through.
45
+ export function resolveVariant(value: any, variants: any): any {
46
+ if (typeof value === 'string') return variants ? variants[value] : undefined;
47
+ return value;
48
+ }
49
+
50
+ // Split a variant target into its animatable values and its `transition` (Framer
51
+ // puts orchestration + per-variant transition options under a `transition` key on the
52
+ // target object; the engine's `animate(el, values, options)` wants them separated).
53
+ export function splitVariant(v: any): { values: any; transition: any } {
54
+ if (v && typeof v === 'object' && !Array.isArray(v) && 'transition' in v) {
55
+ const { transition, ...values } = v;
56
+ return { values, transition };
57
+ }
58
+ return { values: v, transition: undefined };
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,519 @@
1
+ // @octane-ts/motion — Framer Motion for the octane renderer.
2
+ //
3
+ // Reuses motion's framework-agnostic animation engine (`animate`), gesture
4
+ // primitives (`hover`, `press`, `inView`), MotionValues, and scoped animation, and
5
+ // reimplements the `motion.*` components on octane. Each `motion.tag` renders a real
6
+ // host `<tag>` (via octane's `hostComponent` primitive), captures its node, and
7
+ // drives animation/gesture/layout/drag from layout effects — exactly the refs +
8
+ // effects + rendering path this is meant to exercise.
9
+ import { animate, hover, press, inView } from 'motion';
10
+ import { hostComponent, useLayoutEffect, useState, provideContext } from 'octane-ts';
11
+ import {
12
+ MotionConfigContext,
13
+ VariantContext,
14
+ StaggerContext,
15
+ resolveVariant,
16
+ splitVariant,
17
+ } from './context';
18
+ import { isMotionValue, isTransformKey, applyStyleValue } from './useMotionValue';
19
+ import { useContext } from 'octane-ts';
20
+
21
+ // A plain-TS component gets its OWN block per instance (componentSlot), so fixed
22
+ // slot symbols don't collide across instances — and these are distinct within one.
23
+ const REFS = Symbol.for('octane-motion:refs');
24
+ const ENTER = Symbol.for('octane-motion:enter');
25
+ const ANIMATE = Symbol.for('octane-motion:animate');
26
+ const GESTURE = Symbol.for('octane-motion:gesture');
27
+ const EXIT = Symbol.for('octane-motion:exit');
28
+ const LAYOUT = Symbol.for('octane-motion:layout');
29
+ const DRAG = Symbol.for('octane-motion:drag');
30
+ const INVIEW = Symbol.for('octane-motion:inview');
31
+ const MV = Symbol.for('octane-motion:motionvalues');
32
+ const LAYOUT_ID = Symbol.for('octane-motion:layoutid');
33
+ const STAGGER_ORCH = Symbol.for('octane-motion:stagger-orch');
34
+ const STAGGER = Symbol.for('octane-motion:stagger');
35
+
36
+ // Shared-element registry: a `layoutId` element records its box on unmount; the
37
+ // next element to mount with the same id crossfades (FLIPs) from it. (A basic
38
+ // shared-layout "magic move"; the full projection tree is out of scope.)
39
+ interface Box {
40
+ left: number;
41
+ top: number;
42
+ width: number;
43
+ height: number;
44
+ }
45
+ const layoutCells = new Map<string, Box>();
46
+ const boxOf = (n: HTMLElement): Box => {
47
+ const r = n.getBoundingClientRect();
48
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
49
+ };
50
+
51
+ // Props consumed by motion (everything else is spread onto the host element).
52
+ const MOTION_PROPS = new Set([
53
+ 'initial',
54
+ 'animate',
55
+ 'transition',
56
+ 'whileHover',
57
+ 'whileTap',
58
+ 'whileFocus',
59
+ 'whileInView',
60
+ 'viewport',
61
+ 'exit',
62
+ 'layout',
63
+ 'layoutId',
64
+ 'variants',
65
+ 'drag',
66
+ 'dragConstraints',
67
+ 'dragElastic',
68
+ 'dragMomentum',
69
+ 'onDrag',
70
+ 'onDragStart',
71
+ 'onDragEnd',
72
+ 'onAnimationComplete',
73
+ 'children',
74
+ ]);
75
+
76
+ function domProps(props: any): Record<string, any> {
77
+ const out: Record<string, any> = {};
78
+ for (const k in props) {
79
+ if (MOTION_PROPS.has(k)) continue;
80
+ if (k === 'style' && props.style && typeof props.style === 'object') {
81
+ // Motion values + transform shorthands (x/y/scale/…) are applied by the
82
+ // motion-value effect, not written to the DOM as raw style.
83
+ const s: Record<string, any> = {};
84
+ for (const sk in props.style) {
85
+ const v = props.style[sk];
86
+ if (isMotionValue(v) || isTransformKey(sk)) continue;
87
+ s[sk] = v;
88
+ }
89
+ out.style = s;
90
+ } else {
91
+ out[k] = props[k];
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // Cheap structural key so a layout effect re-runs only when the target actually
98
+ // changes (inline objects are a new reference every render).
99
+ function stableKey(v: any): string {
100
+ return v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v);
101
+ }
102
+
103
+ function whenDone(controls: any, done: () => void): void {
104
+ const p = controls && (controls.finished ?? controls);
105
+ if (p && typeof p.then === 'function') p.then(done, done);
106
+ else done();
107
+ }
108
+
109
+ function clamp(v: number, min: number, max: number): number {
110
+ return v < min ? min : v > max ? max : v;
111
+ }
112
+
113
+ function createMotionComponent(tag: string) {
114
+ return function MotionComponent(scope: any, props: any): void {
115
+ const config = useContext(MotionConfigContext);
116
+ const inherited = useContext(VariantContext);
117
+ // Read the PARENT's stagger orchestration before providing our own below.
118
+ const parentStagger = useContext(StaggerContext);
119
+ const variants = props.variants;
120
+
121
+ // Variant labels: an explicit prop wins, else inherit the parent's label.
122
+ const initialLabel = props.initial !== undefined ? props.initial : inherited.initial;
123
+ const animateLabel = props.animate !== undefined ? props.animate : inherited.animate;
124
+ // A child PARTICIPATES in its parent's stagger when it animates via an inherited
125
+ // label rather than its own `animate`.
126
+ const inheritsAnimate = props.animate === undefined && typeof inherited.animate === 'string';
127
+ const { values: resolvedInitial } = splitVariant(resolveVariant(initialLabel, variants));
128
+ const { values: resolvedAnimate, transition: animateVariantTransition } = splitVariant(
129
+ resolveVariant(animateLabel, variants),
130
+ );
131
+ const transition = animateVariantTransition ?? props.transition ?? config.transition;
132
+
133
+ // Stable holder (also our stagger token) — created before we register/provide.
134
+ const [latest] = useState(() => ({}) as any, REFS);
135
+
136
+ // As a child: register with the parent's orchestration to get a stable index.
137
+ if (parentStagger && parentStagger.active && inheritsAnimate) {
138
+ if (!parentStagger.children.includes(latest)) parentStagger.children.push(latest);
139
+ latest.staggerParent = parentStagger;
140
+ } else {
141
+ latest.staggerParent = null;
142
+ }
143
+
144
+ // As a parent: build/refresh OUR orchestration from our (variant) transition and
145
+ // provide it to children.
146
+ const [orch] = useState(
147
+ () =>
148
+ ({
149
+ active: false,
150
+ staggerChildren: 0,
151
+ delayChildren: 0,
152
+ staggerDirection: 1,
153
+ children: [],
154
+ }) as any,
155
+ STAGGER_ORCH,
156
+ );
157
+ const staggerSrc = animateVariantTransition ?? props.transition;
158
+ orch.staggerChildren = staggerSrc?.staggerChildren ?? 0;
159
+ orch.delayChildren = staggerSrc?.delayChildren ?? 0;
160
+ orch.staggerDirection = staggerSrc?.staggerDirection ?? 1;
161
+ orch.active =
162
+ orch.staggerChildren > 0 ||
163
+ orch.delayChildren > 0 ||
164
+ typeof orch.delayChildren === 'function';
165
+
166
+ // Propagate the active labels + stagger orchestration to descendants (passing
167
+ // through inherited labels), before rendering children.
168
+ provideContext(scope, VariantContext, {
169
+ initial: typeof initialLabel === 'string' ? initialLabel : inherited.initial,
170
+ animate: typeof animateLabel === 'string' ? animateLabel : inherited.animate,
171
+ });
172
+ provideContext(scope, StaggerContext, orch);
173
+
174
+ const node = hostComponent(scope, '_m', tag, domProps(props), props.children) as HTMLElement;
175
+
176
+ // Resolve a gesture/exit target to its values + its own (per-variant) transition,
177
+ // so a variant target carrying a `transition` key honors it (like `animate` does).
178
+ const rsv = (v: any) => splitVariant(resolveVariant(v, variants));
179
+ const exitS = rsv(props.exit);
180
+ const hoverS = rsv(props.whileHover);
181
+ const tapS = rsv(props.whileTap);
182
+ const focusS = rsv(props.whileFocus);
183
+ const inViewS = rsv(props.whileInView);
184
+
185
+ latest.node = node;
186
+ latest.transition = transition;
187
+ latest.exit = exitS.values;
188
+ latest.exitTransition = exitS.transition;
189
+ latest.whileHover = hoverS.values;
190
+ latest.whileHoverTransition = hoverS.transition;
191
+ latest.whileTap = tapS.values;
192
+ latest.whileTapTransition = tapS.transition;
193
+ latest.whileFocus = focusS.values;
194
+ latest.whileFocusTransition = focusS.transition;
195
+ latest.whileInView = inViewS.values;
196
+ latest.whileInViewTransition = inViewS.transition;
197
+ latest.base = resolvedAnimate ?? resolvedInitial ?? {};
198
+ latest.drag = props.drag;
199
+ latest.dragConstraints = props.dragConstraints;
200
+ latest.onDrag = props.onDrag;
201
+ latest.onDragStart = props.onDragStart;
202
+ latest.onDragEnd = props.onDragEnd;
203
+
204
+ // `initial`: apply instantly on mount (before the animate effect runs).
205
+ useLayoutEffect(
206
+ () => {
207
+ if (resolvedInitial) animate(node, resolvedInitial, { duration: 0 });
208
+ },
209
+ [],
210
+ ENTER,
211
+ );
212
+
213
+ // `animate`: animate to the (resolved) target on mount and whenever it changes.
214
+ // If we're a stagger child, fold in our per-child delay (our index + the sibling
215
+ // count are both known by now — all children registered during the parent render).
216
+ useLayoutEffect(
217
+ () => {
218
+ if (resolvedAnimate) {
219
+ let t = transition;
220
+ const o = latest.staggerParent;
221
+ if (o && o.active) {
222
+ const index = o.children.indexOf(latest);
223
+ const count = o.children.length;
224
+ let delay: number;
225
+ if (typeof o.delayChildren === 'function') {
226
+ // Framer's stagger()/function form: delayChildren(index, total) IS the delay.
227
+ delay = o.delayChildren(index, count);
228
+ } else {
229
+ const offset = o.staggerDirection === 1 ? index : count - 1 - index;
230
+ delay = (o.delayChildren || 0) + offset * (o.staggerChildren || 0);
231
+ }
232
+ if (delay > 0) t = { ...(t || {}), delay: (t?.delay || 0) + delay };
233
+ }
234
+ const controls = animate(node, resolvedAnimate, t);
235
+ if (props.onAnimationComplete) whenDone(controls, () => props.onAnimationComplete());
236
+ return () => controls.stop();
237
+ }
238
+ },
239
+ [stableKey(resolvedAnimate), stableKey(transition)],
240
+ ANIMATE,
241
+ );
242
+
243
+ // As a stagger child, deregister from the parent's orchestration on unmount so
244
+ // indices/counts stay correct for surviving siblings.
245
+ useLayoutEffect(
246
+ () => () => {
247
+ const o = latest.staggerParent;
248
+ if (o) {
249
+ const i = o.children.indexOf(latest);
250
+ if (i >= 0) o.children.splice(i, 1);
251
+ }
252
+ },
253
+ [],
254
+ STAGGER,
255
+ );
256
+
257
+ // Motion values + static transform shorthands in `style`. MotionValues are
258
+ // subscribed (and update the element without a re-render); shorthands apply once.
259
+ useLayoutEffect(
260
+ () => {
261
+ const style = props.style;
262
+ if (!style || typeof style !== 'object') return;
263
+ const transformState: Record<string, any> = {};
264
+ const cleanups: Array<() => void> = [];
265
+ for (const key in style) {
266
+ const v = style[key];
267
+ if (isMotionValue(v)) {
268
+ const apply = (val: any) => applyStyleValue(node, key, val, transformState);
269
+ apply(v.get());
270
+ cleanups.push(v.on('change', apply));
271
+ } else if (isTransformKey(key)) {
272
+ applyStyleValue(node, key, v, transformState);
273
+ }
274
+ }
275
+ return () => cleanups.forEach((c) => c());
276
+ },
277
+ [],
278
+ MV,
279
+ );
280
+
281
+ // Gestures: `whileHover` / `whileTap` / `whileFocus` animate to the gesture
282
+ // target on start and back to the resting state on end. Targets/transition are
283
+ // read from `latest`, so prop changes take effect without re-binding.
284
+ useLayoutEffect(
285
+ () => {
286
+ const cleanups: Array<() => void> = [];
287
+ const gesture = (
288
+ bind: (el: Element, onStart: () => () => void) => () => void,
289
+ valuesKey: string,
290
+ transitionKey: string,
291
+ ) =>
292
+ bind(node, () => {
293
+ animate(node, latest[valuesKey], latest[transitionKey] ?? latest.transition);
294
+ return () => {
295
+ animate(node, latest.base, latest.transition);
296
+ };
297
+ });
298
+ if (props.whileHover)
299
+ cleanups.push(gesture(hover as any, 'whileHover', 'whileHoverTransition'));
300
+ if (props.whileTap) cleanups.push(gesture(press as any, 'whileTap', 'whileTapTransition'));
301
+ if (props.whileFocus) {
302
+ const onFocus = () =>
303
+ animate(node, latest.whileFocus, latest.whileFocusTransition ?? latest.transition);
304
+ const onBlur = () => animate(node, latest.base, latest.transition);
305
+ node.addEventListener('focus', onFocus);
306
+ node.addEventListener('blur', onBlur);
307
+ cleanups.push(() => {
308
+ node.removeEventListener('focus', onFocus);
309
+ node.removeEventListener('blur', onBlur);
310
+ });
311
+ }
312
+ return () => cleanups.forEach((c) => c());
313
+ },
314
+ [],
315
+ GESTURE,
316
+ );
317
+
318
+ // `whileInView`: animate when the element enters the viewport, and back out
319
+ // when it leaves (unless `viewport.once`). Reuses motion's `inView`.
320
+ useLayoutEffect(
321
+ () => {
322
+ if (!props.whileInView) return;
323
+ const stop = inView(
324
+ node,
325
+ () => {
326
+ animate(node, latest.whileInView, latest.whileInViewTransition ?? latest.transition);
327
+ return () => {
328
+ if (!props.viewport?.once) animate(node, latest.base, latest.transition);
329
+ };
330
+ },
331
+ props.viewport,
332
+ );
333
+ return () => stop();
334
+ },
335
+ [],
336
+ INVIEW,
337
+ );
338
+
339
+ // `drag`: pointer-drag the element, updating its transform. Supports axis lock
340
+ // (`drag="x"`/`"y"`) and `dragConstraints` (a box of left/right/top/bottom px).
341
+ useLayoutEffect(
342
+ () => {
343
+ if (!props.drag) return;
344
+ let active = false;
345
+ let startX = 0;
346
+ let startY = 0;
347
+ let originX = 0;
348
+ let originY = 0;
349
+ latest.dragX ??= 0;
350
+ latest.dragY ??= 0;
351
+ const onDown = (e: any) => {
352
+ active = true;
353
+ startX = e.clientX;
354
+ startY = e.clientY;
355
+ originX = latest.dragX;
356
+ originY = latest.dragY;
357
+ latest.onDragStart?.(e, { point: { x: e.clientX, y: e.clientY } });
358
+ };
359
+ const onMove = (e: any) => {
360
+ if (!active) return;
361
+ let x = latest.drag === 'y' ? originX : originX + (e.clientX - startX);
362
+ let y = latest.drag === 'x' ? originY : originY + (e.clientY - startY);
363
+ const c = latest.dragConstraints;
364
+ if (c) {
365
+ x = clamp(x, c.left ?? -Infinity, c.right ?? Infinity);
366
+ y = clamp(y, c.top ?? -Infinity, c.bottom ?? Infinity);
367
+ }
368
+ latest.dragX = x;
369
+ latest.dragY = y;
370
+ node.style.transform = `translateX(${x}px) translateY(${y}px)`;
371
+ latest.onDrag?.(e, {
372
+ offset: { x: x - originX, y: y - originY },
373
+ point: { x: e.clientX, y: e.clientY },
374
+ });
375
+ };
376
+ const onUp = (e: any) => {
377
+ if (!active) return;
378
+ active = false;
379
+ latest.onDragEnd?.(e, { point: { x: e.clientX, y: e.clientY } });
380
+ };
381
+ node.addEventListener('pointerdown', onDown);
382
+ window.addEventListener('pointermove', onMove);
383
+ window.addEventListener('pointerup', onUp);
384
+ return () => {
385
+ node.removeEventListener('pointerdown', onDown);
386
+ window.removeEventListener('pointermove', onMove);
387
+ window.removeEventListener('pointerup', onUp);
388
+ };
389
+ },
390
+ [],
391
+ DRAG,
392
+ );
393
+
394
+ // `layout`: animate layout changes with FLIP. Each commit, measure the box
395
+ // (transform reset so it's the LAYOUT box, not the transformed one); if it
396
+ // moved/resized vs the previous commit, apply the inverse transform instantly
397
+ // then animate it back to identity. (A single-element FLIP; the full projection
398
+ // tree — nested/shared layout, scale correction — is out of scope.)
399
+ useLayoutEffect(
400
+ () => {
401
+ if (!props.layout) return;
402
+ const prevTransform = node.style.transform;
403
+ node.style.transform = '';
404
+ const r = node.getBoundingClientRect();
405
+ const box = { left: r.left, top: r.top, width: r.width, height: r.height };
406
+ const prev = latest.layoutBox;
407
+ latest.layoutBox = box;
408
+ if (!prev) return;
409
+ const dx = prev.left - box.left;
410
+ const dy = prev.top - box.top;
411
+ const sx = box.width ? prev.width / box.width : 1;
412
+ const sy = box.height ? prev.height / box.height : 1;
413
+ if (dx || dy || sx !== 1 || sy !== 1) {
414
+ node.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
415
+ animate(node, { transform: 'translate(0px, 0px) scale(1, 1)' }, transition);
416
+ } else {
417
+ node.style.transform = prevTransform;
418
+ }
419
+ },
420
+ undefined,
421
+ LAYOUT,
422
+ );
423
+
424
+ // `layoutId`: shared-element crossfade. On mount, if a same-id element recently
425
+ // unmounted, FLIP from its recorded box to ours; on unmount, record our box for
426
+ // the next same-id element (the cleanup runs while still in the DOM).
427
+ useLayoutEffect(
428
+ () => {
429
+ const id = props.layoutId;
430
+ if (!id) return;
431
+ const prev = layoutCells.get(id);
432
+ if (prev) {
433
+ layoutCells.delete(id);
434
+ node.style.transform = '';
435
+ const box = boxOf(node);
436
+ const dx = prev.left - box.left;
437
+ const dy = prev.top - box.top;
438
+ const sx = box.width ? prev.width / box.width : 1;
439
+ const sy = box.height ? prev.height / box.height : 1;
440
+ if (dx || dy || sx !== 1 || sy !== 1) {
441
+ node.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
442
+ animate(node, { transform: 'translate(0px, 0px) scale(1, 1)' }, latest.transition);
443
+ }
444
+ }
445
+ return () => {
446
+ if (node.isConnected) layoutCells.set(id, boxOf(node));
447
+ };
448
+ },
449
+ [],
450
+ LAYOUT_ID,
451
+ );
452
+
453
+ // Exit: this effect's CLEANUP runs on unmount, while the node is still in the
454
+ // DOM (octane fires cleanups before detaching). We clone the leaving node
455
+ // OUTSIDE the block's range (so octane's removal doesn't take the clone),
456
+ // animate the exit on the clone, and remove the clone when it finishes.
457
+ useLayoutEffect(
458
+ () => () => {
459
+ const n: HTMLElement | null = latest.node;
460
+ const exit = latest.exit;
461
+ if (!exit || !n || !n.isConnected || n.parentNode == null) return;
462
+ const parent = n.parentNode as HTMLElement;
463
+ const clone = n.cloneNode(true) as HTMLElement;
464
+ const rect = n.getBoundingClientRect();
465
+ clone.style.position = 'absolute';
466
+ clone.style.top = `${n.offsetTop}px`;
467
+ clone.style.left = `${n.offsetLeft}px`;
468
+ clone.style.width = `${rect.width}px`;
469
+ clone.style.height = `${rect.height}px`;
470
+ parent.appendChild(clone);
471
+ const controls = animate(clone, exit, latest.exitTransition ?? latest.transition);
472
+ whenDone(controls, () => clone.remove());
473
+ },
474
+ [],
475
+ EXIT,
476
+ );
477
+ };
478
+ }
479
+
480
+ // `motion.div`, `motion.span`, … — a proxy that lazily builds (and caches) a
481
+ // component per tag.
482
+ export const motion: any = new Proxy(
483
+ {},
484
+ {
485
+ get(cache: any, tag: string | symbol) {
486
+ if (typeof tag !== 'string') return undefined;
487
+ return cache[tag] ?? (cache[tag] = createMotionComponent(tag));
488
+ },
489
+ },
490
+ );
491
+
492
+ // AnimatePresence — renders its children; each `motion.*` with an `exit` prop
493
+ // self-animates its own removal (see the exit cleanup above), so this is a thin
494
+ // passthrough that exists for drop-in compatibility with Framer Motion's API.
495
+ export function AnimatePresence(scope: any, props: any): void {
496
+ if (typeof props.children === 'function') props.children(scope);
497
+ }
498
+
499
+ // MotionConfig — provides global defaults (transition, reduced motion) to every
500
+ // motion element below it. A plain-TS component: stamps the config context, then
501
+ // renders children.
502
+ export function MotionConfig(scope: any, props: any): void {
503
+ provideContext(scope, MotionConfigContext, {
504
+ transition: props.transition,
505
+ reducedMotion: props.reducedMotion,
506
+ });
507
+ if (typeof props.children === 'function') props.children(scope);
508
+ }
509
+
510
+ export { useAnimate } from './useAnimate';
511
+ export { useMotionValue } from './useMotionValue';
512
+ export { useScroll } from './useScroll';
513
+ export { useTransform } from './useTransform';
514
+ export { useSpring } from './useSpring';
515
+ export { useMotionValueEvent } from './useMotionValueEvent';
516
+ export { MotionConfigContext, VariantContext, StaggerContext } from './context';
517
+
518
+ // Re-export motion's framework-agnostic helpers (animate, stagger, value types, …).
519
+ export * from 'motion';
@@ -0,0 +1,32 @@
1
+ // `useAnimate` — imperative, scoped animations. Returns `[scope, animate]`: attach
2
+ // `scope` to an element (`ref={scope}`), then `animate(scope.current, …)` or
3
+ // `animate('selector', …)` (resolved within the scope). Reuses motion's
4
+ // `createScopedAnimate`; the binding just provides a stable scope object + cleanup.
5
+ import { createScopedAnimate } from 'motion';
6
+ import { useState, useEffect } from 'octane-ts';
7
+
8
+ function sub(slot: symbol | undefined, tag: string): symbol | undefined {
9
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':ua:' + tag) : undefined;
10
+ }
11
+
12
+ export function useAnimate(...args: any[]): [any, any] {
13
+ const tail = args[args.length - 1];
14
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
15
+
16
+ // The scope IS the ref: octane sets `scope.current` to the element when it's
17
+ // passed as `ref={scope}`, and `scope.animations` tracks running animations so
18
+ // they can be stopped on unmount.
19
+ const [scope] = useState(() => ({ current: null, animations: [] }) as any, sub(slot, 'scope'));
20
+ const [animate] = useState(() => createScopedAnimate({ scope }), sub(slot, 'animate'));
21
+
22
+ useEffect(
23
+ () => () => {
24
+ scope.animations.forEach((animation: any) => animation.stop());
25
+ scope.animations.length = 0;
26
+ },
27
+ [],
28
+ sub(slot, 'clean'),
29
+ );
30
+
31
+ return [scope, animate];
32
+ }
@@ -0,0 +1,68 @@
1
+ // `useMotionValue(initial)` — a stable, reactive animatable value (reuses motion's
2
+ // `motionValue`). Bind it to a `motion.*` element via `style={{ x: mv }}`; the
3
+ // element subscribes and updates without re-rendering (see the style-binding effect
4
+ // in index.ts).
5
+ import { motionValue } from 'motion';
6
+ import { useState } from 'octane-ts';
7
+
8
+ export function useMotionValue<T>(initial: T, ...args: any[]): any {
9
+ const tail = args[args.length - 1];
10
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
11
+ const [mv] = useState(
12
+ () => motionValue(initial),
13
+ slot !== undefined ? Symbol.for((slot.description ?? '') + ':mv') : undefined,
14
+ );
15
+ return mv;
16
+ }
17
+
18
+ // A MotionValue duck-typed (reactive get/set/subscribe).
19
+ export function isMotionValue(v: any): boolean {
20
+ return v != null && typeof v.get === 'function' && typeof v.on === 'function';
21
+ }
22
+
23
+ // Transform shorthands → CSS transform functions (matching Framer Motion).
24
+ const TRANSFORM_FN: Record<string, string> = {
25
+ x: 'translateX',
26
+ y: 'translateY',
27
+ z: 'translateZ',
28
+ scale: 'scale',
29
+ scaleX: 'scaleX',
30
+ scaleY: 'scaleY',
31
+ rotate: 'rotate',
32
+ rotateX: 'rotateX',
33
+ rotateY: 'rotateY',
34
+ rotateZ: 'rotateZ',
35
+ skewX: 'skewX',
36
+ skewY: 'skewY',
37
+ };
38
+ const PX_KEYS = new Set(['x', 'y', 'z']);
39
+ const DEG_KEYS = new Set(['rotate', 'rotateX', 'rotateY', 'rotateZ', 'skewX', 'skewY']);
40
+ const NO_UNIT = new Set(['opacity', 'zIndex', 'scale', 'scaleX', 'scaleY']);
41
+
42
+ export function isTransformKey(k: string): boolean {
43
+ return k in TRANSFORM_FN;
44
+ }
45
+
46
+ // Apply one style/transform value to the element, rebuilding the transform string
47
+ // from the accumulated transform-key state.
48
+ export function applyStyleValue(
49
+ node: HTMLElement,
50
+ key: string,
51
+ val: any,
52
+ transformState: Record<string, any>,
53
+ ): void {
54
+ const fn = TRANSFORM_FN[key];
55
+ if (fn) {
56
+ transformState[key] = val;
57
+ let t = '';
58
+ for (const k in transformState) {
59
+ let v = transformState[k];
60
+ if (typeof v === 'number')
61
+ v = PX_KEYS.has(k) ? `${v}px` : DEG_KEYS.has(k) ? `${v}deg` : `${v}`;
62
+ t += `${TRANSFORM_FN[k]}(${v}) `;
63
+ }
64
+ node.style.transform = t.trim();
65
+ } else {
66
+ (node.style as any)[key] = typeof val === 'number' && !NO_UNIT.has(key) ? `${val}px` : val;
67
+ }
68
+ }
@@ -0,0 +1,28 @@
1
+ // `useMotionValueEvent(value, event, callback)` — subscribe `callback` to one of a
2
+ // MotionValue's events ('change' | 'animationStart' | 'animationComplete' |
3
+ // 'animationCancel') for the component's lifetime. Re-subscribes if value/event/
4
+ // callback identity changes; unsubscribes on unmount. Reuses MotionValue's `on`,
5
+ // which returns the unsubscribe fn (and, for 'change', stops idle animations).
6
+ import { useInsertionEffect } from 'octane-ts';
7
+
8
+ function sub(slot: symbol | undefined, tag: string): symbol | undefined {
9
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':umve:' + tag) : undefined;
10
+ }
11
+
12
+ export function useMotionValueEvent(...args: any[]): void {
13
+ const tail = args[args.length - 1];
14
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
15
+ const value = args[0];
16
+ const event = args[1] as string;
17
+ const callback = args[2] as (latest: any) => void;
18
+
19
+ // `value.on(event, cb)` returns the unsubscribe fn → octane uses it as cleanup.
20
+ // Like Framer Motion, subscribe in the INSERTION phase (before layout/passive
21
+ // effects) so a descendant that sets `value` in its own effect can't fire a
22
+ // change before this subscription exists (octane passive effects run child-first).
23
+ useInsertionEffect(
24
+ () => value.on(event, callback),
25
+ [value, event, callback],
26
+ sub(slot, 'effect'),
27
+ );
28
+ }
@@ -0,0 +1,62 @@
1
+ // `useScroll()` — scroll-linked MotionValues. Returns `{ scrollX, scrollY,
2
+ // scrollXProgress, scrollYProgress }`; bind a progress value to a `motion.*`
3
+ // element's style, or read it imperatively. Reuses motion's framework-agnostic
4
+ // `scroll`.
5
+ import { motionValue, scroll } from 'motion';
6
+ import { useState, useEffect } from 'octane-ts';
7
+
8
+ function sub(slot: symbol | undefined, tag: string): symbol | undefined {
9
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':us:' + tag) : undefined;
10
+ }
11
+
12
+ export interface ScrollOptions {
13
+ container?: HTMLElement;
14
+ target?: HTMLElement;
15
+ axis?: 'x' | 'y';
16
+ offset?: any;
17
+ }
18
+
19
+ export function useScroll(...args: any[]): {
20
+ scrollX: any;
21
+ scrollY: any;
22
+ scrollXProgress: any;
23
+ scrollYProgress: any;
24
+ } {
25
+ const tail = args[args.length - 1];
26
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
27
+ const options: ScrollOptions =
28
+ args.length && typeof args[0] === 'object' && args[0] !== null ? args[0] : {};
29
+
30
+ const [values] = useState(
31
+ () => ({
32
+ scrollX: motionValue(0),
33
+ scrollY: motionValue(0),
34
+ scrollXProgress: motionValue(0),
35
+ scrollYProgress: motionValue(0),
36
+ }),
37
+ sub(slot, 'values'),
38
+ );
39
+
40
+ useEffect(
41
+ () => {
42
+ // `scroll(onScroll, options)` reports offset (px) + progress (0–1) each frame.
43
+ const stop = scroll((progress: number, info?: any) => {
44
+ if (info) {
45
+ values.scrollX.set(info.x.current);
46
+ values.scrollY.set(info.y.current);
47
+ values.scrollXProgress.set(info.x.progress);
48
+ values.scrollYProgress.set(info.y.progress);
49
+ } else {
50
+ values.scrollYProgress.set(progress);
51
+ }
52
+ }, options as any);
53
+ return () => {
54
+ if (typeof stop === 'function') stop();
55
+ };
56
+ },
57
+ [options.container, options.target, options.axis],
58
+ sub(slot, 'effect'),
59
+ );
60
+
61
+ return values;
62
+ }
@@ -0,0 +1,57 @@
1
+ // `useSpring(source, options?)` — a spring-backed MotionValue (reuses motion's
2
+ // `attachFollow` engine). Two forms:
3
+ // 1) useSpring(mv, opts) — output springs toward `mv` as it changes (follow).
4
+ // 2) useSpring(initial, opts) — a settable spring; `value.set(target)` springs
5
+ // toward `target` over frames (`value.jump(v)` snaps instantly).
6
+ // Bind the returned MotionValue to a `motion.*` element via `style`, or read it
7
+ // imperatively. Returns the SAME stable MotionValue across renders.
8
+ import { motionValue, attachFollow } from 'motion';
9
+ import { useState, useInsertionEffect } from 'octane-ts';
10
+ import { isMotionValue } from './useMotionValue';
11
+
12
+ function sub(slot: symbol | undefined, tag: string): symbol | undefined {
13
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':usp:' + tag) : undefined;
14
+ }
15
+
16
+ export interface SpringOptions {
17
+ stiffness?: number;
18
+ damping?: number;
19
+ mass?: number;
20
+ duration?: number;
21
+ bounce?: number;
22
+ visualDuration?: number;
23
+ velocity?: number;
24
+ restSpeed?: number;
25
+ restDelta?: number;
26
+ // Jump (don't animate) on the first source change.
27
+ skipInitialAnimation?: boolean;
28
+ }
29
+
30
+ export function useSpring(source: any, ...args: any[]): any {
31
+ const tail = args[args.length - 1];
32
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
33
+ // First non-slot arg after `source` is the options object.
34
+ const options: SpringOptions =
35
+ args.length && typeof args[0] === 'object' && args[0] !== null ? args[0] : {};
36
+
37
+ // Seed the value from the source (matching motion's followValue).
38
+ const [value] = useState(
39
+ () => motionValue(isMotionValue(source) ? source.get() : source),
40
+ sub(slot, 'mv'),
41
+ );
42
+
43
+ // `attachFollow` wires both forms and RETURNS the correct cleanup:
44
+ // - settable: an attach() interceptor that springs on every `.set()`; cleanup
45
+ // stops the running animation.
46
+ // - follow: the above PLUS a `source.on('change')` subscription; cleanup
47
+ // removes the subscription. So returning it gives octane correct teardown.
48
+ // Subscribe in the INSERTION phase (like Framer's useFollowValue) so the follow
49
+ // subscription exists before any descendant can mutate `source` in its own effect.
50
+ useInsertionEffect(
51
+ () => attachFollow(value, source, { type: 'spring', ...options }),
52
+ [JSON.stringify(options)],
53
+ sub(slot, 'attach'),
54
+ );
55
+
56
+ return value;
57
+ }
@@ -0,0 +1,52 @@
1
+ // `useTransform` — derive a MotionValue from one or more inputs. Four forms:
2
+ // 1) useTransform(() => x.get() * 2) — transformer reads other MVs
3
+ // 2) useTransform(mv, [0, 100], [0, 1], options?) — input→output range mapping
4
+ // 3) useTransform([a, b], ([av, bv]) => av + bv) — multiple inputs + combiner
5
+ // 4) useTransform(mv, (latest) => latest * 2) — single input + transformer
6
+ // Reuses motion's `transformValue` (forms 1, 3, 4) and `mapValue` (form 2). The
7
+ // output MotionValue self-subscribes to its inputs (updates are frame-scheduled); we
8
+ // create it once and `destroy()` it on unmount, which tears those subscriptions down.
9
+ import { transformValue, mapValue } from 'motion';
10
+ import { useState, useEffect } from 'octane-ts';
11
+
12
+ function sub(slot: symbol | undefined, tag: string): symbol | undefined {
13
+ return slot !== undefined ? Symbol.for((slot.description ?? '') + ':ut:' + tag) : undefined;
14
+ }
15
+
16
+ export function useTransform(...args: any[]): any {
17
+ const tail = args[args.length - 1];
18
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
19
+ // User args = everything except a trailing compiler-injected slot symbol.
20
+ const a = slot !== undefined ? args.slice(0, -1) : args;
21
+ const [input, second, third, options] = a;
22
+
23
+ const [mv] = useState(
24
+ () => {
25
+ // Form 1: a transformer function that reads other MotionValues internally.
26
+ if (typeof input === 'function') {
27
+ return transformValue(input);
28
+ }
29
+ // Form 3: an array of MotionValue inputs + a combiner over their latest values.
30
+ if (Array.isArray(input)) {
31
+ const inputs = input;
32
+ const combiner = second as (latest: any[]) => any;
33
+ return transformValue(() => combiner(inputs.map((v) => v.get())));
34
+ }
35
+ // Form 4: a single MotionValue + a transformer over its latest scalar value.
36
+ if (typeof second === 'function') {
37
+ const inp = input;
38
+ const fn = second as (latest: any) => any;
39
+ return transformValue(() => fn(inp.get()));
40
+ }
41
+ // Form 2: single MotionValue mapped from inputRange → outputRange.
42
+ return mapValue(input, second as number[], third as any[], options);
43
+ },
44
+ sub(slot, 'mv'),
45
+ );
46
+
47
+ // `transformValue`/`mapValue` register their input-unsubscribe on the output
48
+ // value's `destroy` event, so destroying it on unmount is the correct teardown.
49
+ useEffect(() => () => mv.destroy(), [], sub(slot, 'clean'));
50
+
51
+ return mv;
52
+ }