@octanejs/motion 0.1.32 → 0.1.33

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/README.md CHANGED
@@ -43,6 +43,12 @@ function List(props) @{
43
43
  events, …) and `style` MotionValues spread/bound onto the element.
44
44
  - `AnimatePresence` — exit animations on removal.
45
45
  - `MotionConfig` — global `transition` / `reducedMotion` defaults via context.
46
+ - `useReducedMotion()` — a live `prefers-reduced-motion` subscription; operating-system
47
+ setting changes update mounted consumers.
48
+ - `LayoutGroup` — namespaces `layoutId` values so independent shared-layout surfaces
49
+ do not cross-animate.
50
+ - `LazyMotion` + `domAnimation` / `domMax` + `m` — feature-gated hosts. The
51
+ `./react-m` entry exposes every HTML/SVG host as a named export.
46
52
  - `variants` — label resolution (`animate="visible"`) + parent→child propagation +
47
53
  `staggerChildren` / `delayChildren` (number or `stagger()` function) / `staggerDirection`.
48
54
  - `useMotionValue()`, `useScroll()`, `useAnimate()` — MotionValues, scroll-linked
@@ -74,13 +80,15 @@ real `<tag>` through `hostComponent`, captures the node, and drives:
74
80
  vs the previous commit (`layout`) or a same-id element that just unmounted
75
81
  (`layoutId`) — apply the inverse transform then animate it back to identity. The
76
82
  same cleanup-before-detach ordering lets a leaving `layoutId` element record its
77
- box for the next one.
83
+ box for a same-commit replacement; unused boxes expire after that commit.
84
+ `layout="position"` applies only translation, `layout="size"` only scaling, and
85
+ layout animations use `transition.layout` when provided.
78
86
 
79
87
  ## Not yet ported
80
88
 
81
89
  The full layout **projection tree** — nested projection, child scale correction, and
82
90
  continuous shared-layout during drag (the `layout`/`layoutId` here are single-element
83
- FLIPs). Also drag momentum/elastic physics, reduced-motion enforcement, and
91
+ FLIPs). Also drag momentum/elastic physics and
84
92
  `useTransform`'s output-map form (`useTransform(mv, [0, 100], { opacity: [0, 1] })`).
85
93
 
86
94
  Stagger specifics: `when: 'beforeChildren' | 'afterChildren'` parent/child sequencing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/motion",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -35,20 +35,22 @@
35
35
  "README.md"
36
36
  ],
37
37
  "exports": {
38
- ".": "./src/index.ts"
38
+ ".": "./src/index.ts",
39
+ "./react-m": "./src/react-m.ts"
39
40
  },
40
41
  "dependencies": {
41
42
  "motion": "^12.42.2"
42
43
  },
43
44
  "peerDependencies": {
44
- "octane": "0.1.33"
45
+ "octane": "0.1.34"
45
46
  },
46
47
  "devDependencies": {
47
48
  "esbuild": "^0.28.1",
48
49
  "vitest": "^4.1.10",
49
- "octane": "0.1.33"
50
+ "octane": "0.1.34"
50
51
  },
51
52
  "scripts": {
52
- "test": "vitest run"
53
+ "test": "vitest run --root ../.. --project motion",
54
+ "typecheck": "tsgo --noEmit -p tsconfig.json"
53
55
  }
54
56
  }
package/src/context.ts CHANGED
@@ -6,8 +6,39 @@ import { createContext, useContext } from 'octane';
6
6
  export interface MotionConfigValue {
7
7
  transition?: any;
8
8
  reducedMotion?: 'always' | 'never' | 'user';
9
+ shouldReduceMotion?: boolean;
9
10
  }
10
- export const MotionConfigContext = createContext<MotionConfigValue>({});
11
+ export const MotionConfigContext = createContext<MotionConfigValue>({
12
+ reducedMotion: 'never',
13
+ shouldReduceMotion: false,
14
+ });
15
+
16
+ // LayoutGroup namespaces shared layout ids. Nested groups inherit and compose
17
+ // ids by default, matching Motion's `parent-child-layoutId` convention.
18
+ export interface LayoutGroupValue {
19
+ id?: string;
20
+ /** @internal Collision-free namespace segments used by the layoutId registry. */
21
+ layoutIdNamespace?: readonly string[];
22
+ }
23
+ export const LayoutGroupContext = createContext<LayoutGroupValue>({});
24
+
25
+ export interface MotionFeatureBundle {
26
+ animation: boolean;
27
+ gestures: boolean;
28
+ drag: boolean;
29
+ layout: boolean;
30
+ }
31
+
32
+ export interface LazyMotionValue {
33
+ features: MotionFeatureBundle | null;
34
+ strict: boolean;
35
+ provided: boolean;
36
+ }
37
+ export const LazyMotionContext = createContext<LazyMotionValue>({
38
+ features: null,
39
+ strict: false,
40
+ provided: false,
41
+ });
11
42
 
12
43
  // Variant labels propagated from a parent motion element to its descendants, so a
13
44
  // child with `variants` but no explicit `animate` inherits the parent's active
package/src/index.ts CHANGED
@@ -7,16 +7,37 @@
7
7
  // drives animation/gesture/layout/drag from layout effects — exactly the refs +
8
8
  // effects + rendering path this is meant to exercise.
9
9
  import { animate, hover, press, inView } from 'motion';
10
- import { childSlot, hostComponent, useLayoutEffect, useState, provideContext } from 'octane';
11
10
  import {
11
+ childSlot,
12
+ hostComponent,
13
+ useContext,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useState,
18
+ provideContext,
19
+ } from 'octane';
20
+ import {
21
+ LayoutGroupContext,
22
+ LazyMotionContext,
12
23
  MotionConfigContext,
13
24
  VariantContext,
14
25
  StaggerContext,
26
+ type MotionFeatureBundle,
15
27
  resolveVariant,
16
28
  splitVariant,
17
29
  } from './context';
18
30
  import { isMotionValue, isTransformKey, applyStyleValue } from './useMotionValue';
19
- import { useContext } from 'octane';
31
+ import { useReducedMotionAtSlot } from './useReducedMotion';
32
+ import {
33
+ layoutAnimationMode,
34
+ layoutCellKey,
35
+ layoutTransition,
36
+ projectLayout,
37
+ recordLayoutCell,
38
+ takeLayoutCell,
39
+ type LayoutBox,
40
+ } from './layout';
20
41
 
21
42
  // A plain-TS component gets its OWN block per instance (componentSlot), so fixed
22
43
  // slot symbols don't collide across instances — and these are distinct within one.
@@ -32,18 +53,12 @@ const MV = Symbol.for('octane-motion:motionvalues');
32
53
  const LAYOUT_ID = Symbol.for('octane-motion:layoutid');
33
54
  const STAGGER_ORCH = Symbol.for('octane-motion:stagger-orch');
34
55
  const STAGGER = Symbol.for('octane-motion:stagger');
56
+ const CONFIG_REDUCED = Symbol.for('octane-motion:config-reduced');
57
+ const LAYOUT_GROUP_STATE = Symbol.for('octane-motion:layout-group-state');
58
+ const LAZY_STATE = Symbol.for('octane-motion:lazy-state');
59
+ const LAZY_EFFECT = Symbol.for('octane-motion:lazy-effect');
35
60
 
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 => {
61
+ const boxOf = (n: HTMLElement): LayoutBox => {
47
62
  const r = n.getBoundingClientRect();
48
63
  return { left: r.left, top: r.top, width: r.width, height: r.height };
49
64
  };
@@ -110,13 +125,93 @@ function clamp(v: number, min: number, max: number): number {
110
125
  return v < min ? min : v > max ? max : v;
111
126
  }
112
127
 
113
- function createMotionComponent(tag: string) {
128
+ const POSITIONAL_KEYS = new Set([
129
+ 'width',
130
+ 'height',
131
+ 'top',
132
+ 'left',
133
+ 'right',
134
+ 'bottom',
135
+ 'transform',
136
+ 'transformPerspective',
137
+ 'x',
138
+ 'y',
139
+ 'z',
140
+ 'translateX',
141
+ 'translateY',
142
+ 'translateZ',
143
+ 'scale',
144
+ 'scaleX',
145
+ 'scaleY',
146
+ 'rotate',
147
+ 'rotateX',
148
+ 'rotateY',
149
+ 'rotateZ',
150
+ 'skew',
151
+ 'skewX',
152
+ 'skewY',
153
+ ]);
154
+
155
+ function reducedTransition(values: any, transition: any, shouldReduceMotion: boolean): any {
156
+ if (!shouldReduceMotion || values == null || typeof values !== 'object') return transition;
157
+
158
+ let reduced: Record<string, any> | null = null;
159
+ for (const key in values) {
160
+ if (!POSITIONAL_KEYS.has(key)) continue;
161
+ const next = (reduced ??= { ...(transition || {}) });
162
+ const valueTransition =
163
+ transition?.[key] != null && typeof transition[key] === 'object' ? transition[key] : {};
164
+ next[key] = { ...valueTransition, type: false, delay: 0, duration: 0 };
165
+ }
166
+ return reduced ?? transition;
167
+ }
168
+
169
+ function animateTarget(
170
+ node: HTMLElement,
171
+ values: any,
172
+ transition: any,
173
+ shouldReduceMotion: boolean,
174
+ ): any {
175
+ return animate(node, values, reducedTransition(values, transition, shouldReduceMotion));
176
+ }
177
+
178
+ export type MotionComponent = (props: any, scope: any) => void;
179
+
180
+ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionComponent {
114
181
  return function MotionComponent(props: any, scope: any): void {
115
182
  const config = useContext(MotionConfigContext);
183
+ const layoutGroup = useContext(LayoutGroupContext);
184
+ const lazy = useContext(LazyMotionContext);
116
185
  const inherited = useContext(VariantContext);
117
186
  // Read the PARENT's stagger orchestration before providing our own below.
118
187
  const parentStagger = useContext(StaggerContext);
119
188
  const variants = props.variants;
189
+ const features = preloadedFeatures ? domMax : lazy.features;
190
+ const animationEnabled = preloadedFeatures || features?.animation === true;
191
+ const gesturesEnabled = preloadedFeatures || features?.gestures === true;
192
+ const dragEnabled = preloadedFeatures || features?.drag === true;
193
+ const layoutEnabled = preloadedFeatures || features?.layout === true;
194
+ const shouldReduceMotion = config.shouldReduceMotion === true;
195
+ const layoutIdKey =
196
+ props.layoutId === undefined
197
+ ? undefined
198
+ : layoutCellKey(
199
+ layoutGroup.layoutIdNamespace ?? (layoutGroup.id ? [layoutGroup.id] : undefined),
200
+ props.layoutId,
201
+ );
202
+
203
+ // Reason: browser bundlers replace this expression even though they do not
204
+ // define a global `process`; guarding `process` would silently disable strict mode.
205
+ if (
206
+ process.env.NODE_ENV !== 'production' &&
207
+ preloadedFeatures &&
208
+ lazy.provided &&
209
+ lazy.strict
210
+ ) {
211
+ throw new Error(
212
+ 'You have rendered a `motion` component within a strict `LazyMotion` component. Import and render from `@octanejs/motion/react-m` instead.',
213
+ );
214
+ }
120
215
 
121
216
  // Variant labels: an explicit prop wins, else inherit the parent's label.
122
217
  const initialLabel = props.initial !== undefined ? props.initial : inherited.initial;
@@ -201,11 +296,18 @@ function createMotionComponent(tag: string) {
201
296
  latest.onDrag = props.onDrag;
202
297
  latest.onDragStart = props.onDragStart;
203
298
  latest.onDragEnd = props.onDragEnd;
299
+ latest.shouldReduceMotion = shouldReduceMotion;
300
+ latest.animationEnabled = animationEnabled;
301
+ latest.layoutEnabled = layoutEnabled;
302
+ latest.layoutIdKey = layoutIdKey;
303
+ latest.layoutMode = layoutAnimationMode(props.layout);
204
304
 
205
305
  // `initial`: apply instantly on mount (before the animate effect runs).
206
306
  useLayoutEffect(
207
307
  () => {
208
- if (resolvedInitial) animate(node, resolvedInitial, { duration: 0 });
308
+ if (animationEnabled && resolvedInitial) {
309
+ animate(node, resolvedInitial, { duration: 0 });
310
+ }
209
311
  },
210
312
  [],
211
313
  ENTER,
@@ -216,7 +318,7 @@ function createMotionComponent(tag: string) {
216
318
  // count are both known by now — all children registered during the parent render).
217
319
  useLayoutEffect(
218
320
  () => {
219
- if (resolvedAnimate) {
321
+ if (animationEnabled && resolvedAnimate) {
220
322
  let t = transition;
221
323
  const o = latest.staggerParent;
222
324
  if (o && o.active) {
@@ -232,12 +334,12 @@ function createMotionComponent(tag: string) {
232
334
  }
233
335
  if (delay > 0) t = { ...(t || {}), delay: (t?.delay || 0) + delay };
234
336
  }
235
- const controls = animate(node, resolvedAnimate, t);
337
+ const controls = animateTarget(node, resolvedAnimate, t, latest.shouldReduceMotion);
236
338
  if (props.onAnimationComplete) whenDone(controls, () => props.onAnimationComplete());
237
339
  return () => controls.stop();
238
340
  }
239
341
  },
240
- [stableKey(resolvedAnimate), stableKey(transition)],
342
+ [animationEnabled, stableKey(resolvedAnimate), stableKey(transition), shouldReduceMotion],
241
343
  ANIMATE,
242
344
  );
243
345
 
@@ -284,6 +386,7 @@ function createMotionComponent(tag: string) {
284
386
  // read from `latest`, so prop changes take effect without re-binding.
285
387
  useLayoutEffect(
286
388
  () => {
389
+ if (!gesturesEnabled) return;
287
390
  const cleanups: Array<() => void> = [];
288
391
  const gesture = (
289
392
  bind: (el: Element, onStart: () => () => void) => () => void,
@@ -291,9 +394,14 @@ function createMotionComponent(tag: string) {
291
394
  transitionKey: string,
292
395
  ) =>
293
396
  bind(node, () => {
294
- animate(node, latest[valuesKey], latest[transitionKey] ?? latest.transition);
397
+ animateTarget(
398
+ node,
399
+ latest[valuesKey],
400
+ latest[transitionKey] ?? latest.transition,
401
+ latest.shouldReduceMotion,
402
+ );
295
403
  return () => {
296
- animate(node, latest.base, latest.transition);
404
+ animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
297
405
  };
298
406
  });
299
407
  if (props.whileHover)
@@ -301,8 +409,14 @@ function createMotionComponent(tag: string) {
301
409
  if (props.whileTap) cleanups.push(gesture(press as any, 'whileTap', 'whileTapTransition'));
302
410
  if (props.whileFocus) {
303
411
  const onFocus = () =>
304
- animate(node, latest.whileFocus, latest.whileFocusTransition ?? latest.transition);
305
- const onBlur = () => animate(node, latest.base, latest.transition);
412
+ animateTarget(
413
+ node,
414
+ latest.whileFocus,
415
+ latest.whileFocusTransition ?? latest.transition,
416
+ latest.shouldReduceMotion,
417
+ );
418
+ const onBlur = () =>
419
+ animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
306
420
  node.addEventListener('focus', onFocus);
307
421
  node.addEventListener('blur', onBlur);
308
422
  cleanups.push(() => {
@@ -312,7 +426,7 @@ function createMotionComponent(tag: string) {
312
426
  }
313
427
  return () => cleanups.forEach((c) => c());
314
428
  },
315
- [],
429
+ [gesturesEnabled],
316
430
  GESTURE,
317
431
  );
318
432
 
@@ -320,20 +434,27 @@ function createMotionComponent(tag: string) {
320
434
  // when it leaves (unless `viewport.once`). Reuses motion's `inView`.
321
435
  useLayoutEffect(
322
436
  () => {
323
- if (!props.whileInView) return;
437
+ if (!gesturesEnabled || !props.whileInView) return;
324
438
  const stop = inView(
325
439
  node,
326
440
  () => {
327
- animate(node, latest.whileInView, latest.whileInViewTransition ?? latest.transition);
441
+ animateTarget(
442
+ node,
443
+ latest.whileInView,
444
+ latest.whileInViewTransition ?? latest.transition,
445
+ latest.shouldReduceMotion,
446
+ );
328
447
  return () => {
329
- if (!props.viewport?.once) animate(node, latest.base, latest.transition);
448
+ if (!props.viewport?.once) {
449
+ animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
450
+ }
330
451
  };
331
452
  },
332
453
  props.viewport,
333
454
  );
334
455
  return () => stop();
335
456
  },
336
- [],
457
+ [gesturesEnabled],
337
458
  INVIEW,
338
459
  );
339
460
 
@@ -341,7 +462,7 @@ function createMotionComponent(tag: string) {
341
462
  // (`drag="x"`/`"y"`) and `dragConstraints` (a box of left/right/top/bottom px).
342
463
  useLayoutEffect(
343
464
  () => {
344
- if (!props.drag) return;
465
+ if (!dragEnabled || !props.drag) return;
345
466
  let active = false;
346
467
  let startX = 0;
347
468
  let startY = 0;
@@ -388,7 +509,7 @@ function createMotionComponent(tag: string) {
388
509
  window.removeEventListener('pointerup', onUp);
389
510
  };
390
511
  },
391
- [],
512
+ [dragEnabled],
392
513
  DRAG,
393
514
  );
394
515
 
@@ -399,21 +520,28 @@ function createMotionComponent(tag: string) {
399
520
  // tree — nested/shared layout, scale correction — is out of scope.)
400
521
  useLayoutEffect(
401
522
  () => {
402
- if (!props.layout) return;
523
+ if (!layoutEnabled || !props.layout) {
524
+ latest.layoutBox = undefined;
525
+ return;
526
+ }
403
527
  const prevTransform = node.style.transform;
404
528
  node.style.transform = '';
405
529
  const r = node.getBoundingClientRect();
406
530
  const box = { left: r.left, top: r.top, width: r.width, height: r.height };
407
531
  const prev = latest.layoutBox;
408
532
  latest.layoutBox = box;
409
- if (!prev) return;
410
- const dx = prev.left - box.left;
411
- const dy = prev.top - box.top;
412
- const sx = box.width ? prev.width / box.width : 1;
413
- const sy = box.height ? prev.height / box.height : 1;
414
- if (dx || dy || sx !== 1 || sy !== 1) {
415
- node.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
416
- animate(node, { transform: 'translate(0px, 0px) scale(1, 1)' }, transition);
533
+ if (!prev) {
534
+ node.style.transform = prevTransform;
535
+ return;
536
+ }
537
+ const projection = projectLayout(prev, box, latest.layoutMode);
538
+ if (projection) {
539
+ if (latest.shouldReduceMotion) {
540
+ node.style.transform = prevTransform;
541
+ } else {
542
+ node.style.transform = projection.from;
543
+ animate(node, { transform: projection.to }, layoutTransition(latest.transition));
544
+ }
417
545
  } else {
418
546
  node.style.transform = prevTransform;
419
547
  }
@@ -423,41 +551,43 @@ function createMotionComponent(tag: string) {
423
551
  );
424
552
 
425
553
  // `layoutId`: shared-element crossfade. On mount, if a same-id element recently
426
- // unmounted, FLIP from its recorded box to ours; on unmount, record our box for
427
- // the next same-id element (the cleanup runs while still in the DOM).
554
+ // unmounted, FLIP from its recorded box to ours.
428
555
  useLayoutEffect(
429
556
  () => {
430
- const id = props.layoutId;
557
+ if (!layoutEnabled) return;
558
+ const id = layoutIdKey;
431
559
  if (!id) return;
432
- const prev = layoutCells.get(id);
560
+ const prev = takeLayoutCell(id);
433
561
  if (prev) {
434
- layoutCells.delete(id);
435
- node.style.transform = '';
436
- const box = boxOf(node);
437
- const dx = prev.left - box.left;
438
- const dy = prev.top - box.top;
439
- const sx = box.width ? prev.width / box.width : 1;
440
- const sy = box.height ? prev.height / box.height : 1;
441
- if (dx || dy || sx !== 1 || sy !== 1) {
442
- node.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
443
- animate(node, { transform: 'translate(0px, 0px) scale(1, 1)' }, latest.transition);
562
+ if (!latest.shouldReduceMotion) {
563
+ const prevTransform = node.style.transform;
564
+ node.style.transform = '';
565
+ const box = boxOf(node);
566
+ const projection = projectLayout(prev, box, latest.layoutMode);
567
+ if (projection) {
568
+ node.style.transform = projection.from;
569
+ animate(node, { transform: projection.to }, layoutTransition(latest.transition));
570
+ } else {
571
+ node.style.transform = prevTransform;
572
+ }
444
573
  }
445
574
  }
446
- return () => {
447
- if (node.isConnected) layoutCells.set(id, boxOf(node));
448
- };
449
575
  },
450
- [],
576
+ [layoutEnabled, layoutIdKey],
451
577
  LAYOUT_ID,
452
578
  );
453
579
 
454
- // Exit: this effect's CLEANUP runs on unmount, while the node is still in the
455
- // DOM (octane fires cleanups before detaching). We clone the leaving node
456
- // OUTSIDE the block's range (so octane's removal doesn't take the clone),
457
- // animate the exit on the clone, and remove the clone when it finishes.
580
+ // Unmount: this cleanup runs while the node is still in the DOM. Record shared
581
+ // layout state here—not in the keyed claim effect, whose cleanup also runs on
582
+ // dependency changes—then clone any exiting node outside the block's range.
458
583
  useLayoutEffect(
459
584
  () => () => {
460
585
  const n: HTMLElement | null = latest.node;
586
+ const layoutId = latest.layoutIdKey;
587
+ if (latest.layoutEnabled && layoutId && n?.isConnected) {
588
+ recordLayoutCell(layoutId, boxOf(n));
589
+ }
590
+ if (!latest.animationEnabled) return;
461
591
  const exit = latest.exit;
462
592
  if (!exit || !n || !n.isConnected || n.parentNode == null) return;
463
593
  const parent = n.parentNode as HTMLElement;
@@ -469,7 +599,12 @@ function createMotionComponent(tag: string) {
469
599
  clone.style.width = `${rect.width}px`;
470
600
  clone.style.height = `${rect.height}px`;
471
601
  parent.appendChild(clone);
472
- const controls = animate(clone, exit, latest.exitTransition ?? latest.transition);
602
+ const controls = animateTarget(
603
+ clone,
604
+ exit,
605
+ latest.exitTransition ?? latest.transition,
606
+ latest.shouldReduceMotion,
607
+ );
473
608
  whenDone(controls, () => clone.remove());
474
609
  },
475
610
  [],
@@ -478,17 +613,41 @@ function createMotionComponent(tag: string) {
478
613
  };
479
614
  }
480
615
 
616
+ export const domAnimation: MotionFeatureBundle = Object.freeze({
617
+ animation: true,
618
+ gestures: true,
619
+ drag: false,
620
+ layout: false,
621
+ });
622
+
623
+ export const domMax: MotionFeatureBundle = Object.freeze({
624
+ animation: true,
625
+ gestures: true,
626
+ drag: true,
627
+ layout: true,
628
+ });
629
+
630
+ export type MotionProxy = Record<string, MotionComponent>;
631
+
632
+ function motionProxy(preloadedFeatures: boolean): MotionProxy {
633
+ return new Proxy(
634
+ {},
635
+ {
636
+ get(cache: Record<string, MotionComponent>, tag: string | symbol) {
637
+ if (typeof tag !== 'string') return undefined;
638
+ return cache[tag] ?? (cache[tag] = createMotionComponent(tag, preloadedFeatures));
639
+ },
640
+ },
641
+ );
642
+ }
643
+
481
644
  // `motion.div`, `motion.span`, … — a proxy that lazily builds (and caches) a
482
645
  // component per tag.
483
- export const motion: any = new Proxy(
484
- {},
485
- {
486
- get(cache: any, tag: string | symbol) {
487
- if (typeof tag !== 'string') return undefined;
488
- return cache[tag] ?? (cache[tag] = createMotionComponent(tag));
489
- },
490
- },
491
- );
646
+ export const motion = motionProxy(true);
647
+
648
+ // `m` renders the same hosts but activates animation/gesture/layout behavior
649
+ // only from its nearest LazyMotion feature bundle.
650
+ export const m = motionProxy(false);
492
651
 
493
652
  // AnimatePresence — renders its children; each `motion.*` with an `exit` prop
494
653
  // self-animates its own removal (see the exit cleanup above), so this is a thin
@@ -497,13 +656,122 @@ export function AnimatePresence(props: any, scope: any): void {
497
656
  renderTransparentChildren(props.children, scope);
498
657
  }
499
658
 
659
+ export interface LayoutGroupProps {
660
+ children?: unknown;
661
+ id?: string;
662
+ inherit?: boolean | 'id';
663
+ }
664
+
665
+ export function LayoutGroup(props: LayoutGroupProps, scope: any): void {
666
+ const parent = useContext(LayoutGroupContext);
667
+ const value = useMemo(
668
+ () => {
669
+ const inherit = props.inherit ?? true;
670
+ const inheritId = inherit === true || inherit === 'id';
671
+ const id =
672
+ inheritId && parent.id ? (props.id ? `${parent.id}-${props.id}` : parent.id) : props.id;
673
+ const parentNamespace = parent.layoutIdNamespace ?? (parent.id ? [parent.id] : undefined);
674
+ const layoutIdNamespace =
675
+ inheritId && parent.id
676
+ ? props.id
677
+ ? [...(parentNamespace ?? [parent.id]), props.id]
678
+ : parentNamespace
679
+ : props.id
680
+ ? [props.id]
681
+ : undefined;
682
+ return { id, layoutIdNamespace };
683
+ },
684
+ [parent.id, parent.layoutIdNamespace, props.id, props.inherit],
685
+ LAYOUT_GROUP_STATE,
686
+ );
687
+ provideContext(scope, LayoutGroupContext, value);
688
+ renderTransparentChildren(props.children, scope);
689
+ }
690
+
500
691
  // MotionConfig — provides global defaults (transition, reduced motion) to every
501
692
  // motion element below it. A plain-TS component: stamps the config context, then
502
693
  // renders children.
503
694
  export function MotionConfig(props: any, scope: any): void {
695
+ const parent = useContext(MotionConfigContext);
696
+ const reducedMotion = props.reducedMotion ?? parent.reducedMotion ?? 'never';
697
+ // Octane slots hooks by call site, so this subscription can stay conditional.
698
+ // Avoid paying for matchMedia listeners when the policy never consults it.
699
+ const userPreference = reducedMotion === 'user' ? useReducedMotionAtSlot(CONFIG_REDUCED) : false;
504
700
  provideContext(scope, MotionConfigContext, {
505
- transition: props.transition,
506
- reducedMotion: props.reducedMotion,
701
+ transition: props.transition ?? parent.transition,
702
+ reducedMotion,
703
+ shouldReduceMotion: reducedMotion === 'always' || (reducedMotion === 'user' && userPreference),
704
+ });
705
+ renderTransparentChildren(props.children, scope);
706
+ }
707
+
708
+ export type LazyMotionFeatures =
709
+ | MotionFeatureBundle
710
+ | (() =>
711
+ | Promise<MotionFeatureBundle | { default: MotionFeatureBundle }>
712
+ | MotionFeatureBundle
713
+ | { default: MotionFeatureBundle });
714
+
715
+ export interface LazyMotionProps {
716
+ children?: unknown;
717
+ features: LazyMotionFeatures;
718
+ strict?: boolean;
719
+ }
720
+
721
+ interface LoadedLazyFeatures {
722
+ features: MotionFeatureBundle | null;
723
+ error?: { value: unknown };
724
+ }
725
+
726
+ function normalizeFeatures(
727
+ value: MotionFeatureBundle | { default: MotionFeatureBundle },
728
+ ): MotionFeatureBundle {
729
+ return 'default' in value ? value.default : value;
730
+ }
731
+
732
+ export function LazyMotion(props: LazyMotionProps, scope: any): void {
733
+ const [loaded, setLoaded] = useState<LoadedLazyFeatures>(() => ({ features: null }), LAZY_STATE);
734
+ const source = props.features;
735
+ const isLoader = typeof source === 'function';
736
+ const features = isLoader ? loaded.features : normalizeFeatures(source);
737
+ if (isLoader && loaded.error) throw loaded.error.value;
738
+
739
+ // Match Motion's mount-time loader semantics. In particular, an idiomatic
740
+ // inline `features={() => import(...)}` must survive parent re-renders.
741
+ useEffect(
742
+ () => {
743
+ if (typeof source !== 'function') return;
744
+ let current = true;
745
+ const fail = (error: unknown) => {
746
+ if (current) setLoaded({ features: null, error: { value: error } });
747
+ };
748
+ try {
749
+ void Promise.resolve(source()).then((value) => {
750
+ if (!current) return;
751
+ let features: MotionFeatureBundle;
752
+ try {
753
+ features = normalizeFeatures(value);
754
+ } catch (error) {
755
+ fail(error);
756
+ return;
757
+ }
758
+ setLoaded({ features });
759
+ }, fail);
760
+ } catch (error) {
761
+ fail(error);
762
+ }
763
+ return () => {
764
+ current = false;
765
+ };
766
+ },
767
+ [],
768
+ LAZY_EFFECT,
769
+ );
770
+
771
+ provideContext(scope, LazyMotionContext, {
772
+ features,
773
+ strict: props.strict === true,
774
+ provided: true,
507
775
  });
508
776
  renderTransparentChildren(props.children, scope);
509
777
  }
@@ -518,7 +786,15 @@ export { useScroll } from './useScroll';
518
786
  export { useTransform } from './useTransform';
519
787
  export { useSpring } from './useSpring';
520
788
  export { useMotionValueEvent } from './useMotionValueEvent';
521
- export { MotionConfigContext, VariantContext, StaggerContext } from './context';
789
+ export { useReducedMotion } from './useReducedMotion';
790
+ export {
791
+ LayoutGroupContext,
792
+ LazyMotionContext,
793
+ MotionConfigContext,
794
+ VariantContext,
795
+ StaggerContext,
796
+ } from './context';
522
797
 
523
798
  // Re-export motion's framework-agnostic helpers (animate, stagger, value types, …).
524
799
  export * from 'motion';
800
+ export type { TargetAndTransition, Transition } from 'motion';
package/src/layout.ts ADDED
@@ -0,0 +1,102 @@
1
+ export interface LayoutBox {
2
+ left: number;
3
+ top: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+
8
+ export type LayoutAnimationMode = true | 'position' | 'size';
9
+
10
+ export interface LayoutProjection {
11
+ from: string;
12
+ to: string;
13
+ }
14
+
15
+ export function layoutAnimationMode(layout: unknown): LayoutAnimationMode {
16
+ return layout === 'position' || layout === 'size' ? layout : true;
17
+ }
18
+
19
+ export function projectLayout(
20
+ previous: LayoutBox,
21
+ next: LayoutBox,
22
+ mode: LayoutAnimationMode,
23
+ ): LayoutProjection | null {
24
+ const translate = mode !== 'size';
25
+ const scale = mode !== 'position';
26
+ const x = translate ? previous.left - next.left : 0;
27
+ const y = translate ? previous.top - next.top : 0;
28
+ const scaleX = scale && next.width ? previous.width / next.width : 1;
29
+ const scaleY = scale && next.height ? previous.height / next.height : 1;
30
+
31
+ if (x === 0 && y === 0 && scaleX === 1 && scaleY === 1) return null;
32
+ if (mode === 'position') {
33
+ return {
34
+ from: `translate(${x}px, ${y}px)`,
35
+ to: 'translate(0px, 0px)',
36
+ };
37
+ }
38
+ if (mode === 'size') {
39
+ return {
40
+ from: `scale(${scaleX}, ${scaleY})`,
41
+ to: 'scale(1, 1)',
42
+ };
43
+ }
44
+ return {
45
+ from: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`,
46
+ to: 'translate(0px, 0px) scale(1, 1)',
47
+ };
48
+ }
49
+
50
+ export function layoutTransition(transition: any): any {
51
+ const selected = transition?.layout ?? transition?.default ?? transition;
52
+ if (selected !== transition && selected?.inherit && transition) {
53
+ const { inherit: _inherit, ...valueTransition } = selected;
54
+ return { ...transition, ...valueTransition };
55
+ }
56
+ return selected;
57
+ }
58
+
59
+ export function layoutCellKey(namespace: readonly string[] | undefined, layoutId: unknown): string {
60
+ let key = '';
61
+ if (namespace) {
62
+ for (const segment of namespace) key += `${segment.length}:${segment}|`;
63
+ }
64
+ const id = String(layoutId);
65
+ return `${key}${id.length}:${id}`;
66
+ }
67
+
68
+ // Shared layout snapshots only bridge replacements in the current synchronous
69
+ // commit. Preserve every handoff in that commit, then expire anything unclaimed
70
+ // at the next microtask so no snapshot survives into a later turn.
71
+ const layoutCells = new Map<string, LayoutBox>();
72
+ let pendingClear: object | null = null;
73
+
74
+ function scheduleLayoutCellClear(): void {
75
+ if (pendingClear) return;
76
+ const token = {};
77
+ pendingClear = token;
78
+ queueMicrotask(() => {
79
+ if (pendingClear !== token) return;
80
+ layoutCells.clear();
81
+ pendingClear = null;
82
+ });
83
+ }
84
+
85
+ export function recordLayoutCell(id: string, box: LayoutBox): void {
86
+ layoutCells.set(id, box);
87
+ scheduleLayoutCellClear();
88
+ }
89
+
90
+ export function takeLayoutCell(id: string): LayoutBox | undefined {
91
+ const box = layoutCells.get(id);
92
+ if (box) layoutCells.delete(id);
93
+ return box;
94
+ }
95
+
96
+ export const layoutCellsForTests = {
97
+ size: (): number => layoutCells.size,
98
+ reset(): void {
99
+ layoutCells.clear();
100
+ pendingClear = null;
101
+ },
102
+ };
package/src/react-m.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Tree-shakable-style named host surface matching `motion/react-m`.
2
+ //
3
+ // Each export is the lazy-feature variant of its corresponding motion host.
4
+ // Consumers normally use this entry as a namespace:
5
+ // import * as m from '@octanejs/motion/react-m'
6
+ // <m.div />
7
+ import { m } from './index';
8
+
9
+ export const {
10
+ a,
11
+ abbr,
12
+ address,
13
+ animate,
14
+ area,
15
+ article,
16
+ aside,
17
+ audio,
18
+ b,
19
+ base,
20
+ bdi,
21
+ bdo,
22
+ big,
23
+ blockquote,
24
+ body,
25
+ button,
26
+ canvas,
27
+ caption,
28
+ circle,
29
+ cite,
30
+ clipPath,
31
+ code,
32
+ col,
33
+ colgroup,
34
+ data,
35
+ datalist,
36
+ dd,
37
+ defs,
38
+ del,
39
+ desc,
40
+ details,
41
+ dfn,
42
+ dialog,
43
+ div,
44
+ dl,
45
+ dt,
46
+ ellipse,
47
+ em,
48
+ embed,
49
+ feBlend,
50
+ feColorMatrix,
51
+ feComponentTransfer,
52
+ feComposite,
53
+ feConvolveMatrix,
54
+ feDiffuseLighting,
55
+ feDisplacementMap,
56
+ feDistantLight,
57
+ feDropShadow,
58
+ feFlood,
59
+ feFuncA,
60
+ feFuncB,
61
+ feFuncG,
62
+ feFuncR,
63
+ feGaussianBlur,
64
+ feImage,
65
+ feMerge,
66
+ feMergeNode,
67
+ feMorphology,
68
+ feOffset,
69
+ fePointLight,
70
+ feSpecularLighting,
71
+ feSpotLight,
72
+ feTile,
73
+ feTurbulence,
74
+ fieldset,
75
+ figcaption,
76
+ figure,
77
+ filter,
78
+ footer,
79
+ foreignObject,
80
+ form,
81
+ g,
82
+ h1,
83
+ h2,
84
+ h3,
85
+ h4,
86
+ h5,
87
+ h6,
88
+ head,
89
+ header,
90
+ hgroup,
91
+ hr,
92
+ html,
93
+ i,
94
+ iframe,
95
+ image,
96
+ img,
97
+ input,
98
+ ins,
99
+ kbd,
100
+ keygen,
101
+ label,
102
+ legend,
103
+ li,
104
+ line,
105
+ linearGradient,
106
+ link,
107
+ main,
108
+ map,
109
+ mark,
110
+ marker,
111
+ mask,
112
+ menu,
113
+ menuitem,
114
+ metadata,
115
+ meter,
116
+ nav,
117
+ object,
118
+ ol,
119
+ optgroup,
120
+ option,
121
+ output,
122
+ p,
123
+ param,
124
+ path,
125
+ pattern,
126
+ picture,
127
+ polygon,
128
+ polyline,
129
+ pre,
130
+ progress,
131
+ q,
132
+ radialGradient,
133
+ rect,
134
+ rp,
135
+ rt,
136
+ ruby,
137
+ s,
138
+ samp,
139
+ script,
140
+ section,
141
+ select,
142
+ small,
143
+ source,
144
+ span,
145
+ stop,
146
+ strong,
147
+ style,
148
+ sub,
149
+ summary,
150
+ sup,
151
+ svg,
152
+ symbol,
153
+ table,
154
+ tbody,
155
+ td,
156
+ text,
157
+ textPath,
158
+ textarea,
159
+ tfoot,
160
+ th,
161
+ thead,
162
+ time,
163
+ title,
164
+ tr,
165
+ track,
166
+ tspan,
167
+ u,
168
+ ul,
169
+ use,
170
+ video,
171
+ view,
172
+ wbr,
173
+ webview,
174
+ } = m;
@@ -0,0 +1,86 @@
1
+ // `useReducedMotion()` — subscribe to the user's operating-system preference.
2
+ //
3
+ // Motion's React hook reads the shared `prefers-reduced-motion` query. This
4
+ // binding keeps the query shared too, but also publishes later `change` events
5
+ // so mounted Octane consumers update when the setting changes.
6
+ import { useSyncExternalStore } from 'octane';
7
+
8
+ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
9
+
10
+ type MatchMedia = typeof window.matchMedia;
11
+
12
+ let activeMatchMedia: MatchMedia | null = null;
13
+ let activeQuery: MediaQueryList | null = null;
14
+ let detachActiveQuery: (() => void) | null = null;
15
+ const listeners = new Set<() => void>();
16
+
17
+ function notifyListeners(): void {
18
+ for (const listener of listeners) listener();
19
+ }
20
+
21
+ function attachQuery(query: MediaQueryList): () => void {
22
+ const onChange = () => notifyListeners();
23
+
24
+ if (typeof query.addEventListener === 'function') {
25
+ query.addEventListener('change', onChange);
26
+ return () => query.removeEventListener('change', onChange);
27
+ }
28
+
29
+ // Safari < 14 exposes the deprecated MediaQueryList listener API only.
30
+ query.addListener(onChange);
31
+ return () => query.removeListener(onChange);
32
+ }
33
+
34
+ function getQuery(): MediaQueryList | null {
35
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
36
+ return null;
37
+ }
38
+
39
+ const matchMedia = window.matchMedia;
40
+ if (activeQuery === null || activeMatchMedia !== matchMedia) {
41
+ detachActiveQuery?.();
42
+ detachActiveQuery = null;
43
+ activeMatchMedia = matchMedia;
44
+ activeQuery = matchMedia.call(window, REDUCED_MOTION_QUERY);
45
+ if (listeners.size > 0) detachActiveQuery = attachQuery(activeQuery);
46
+ }
47
+
48
+ return activeQuery;
49
+ }
50
+
51
+ function getSnapshot(): boolean {
52
+ return getQuery()?.matches ?? false;
53
+ }
54
+
55
+ function getServerSnapshot(): boolean {
56
+ return false;
57
+ }
58
+
59
+ function subscribe(listener: () => void): () => void {
60
+ listeners.add(listener);
61
+ const query = getQuery();
62
+ if (query !== null && detachActiveQuery === null) {
63
+ detachActiveQuery = attachQuery(query);
64
+ }
65
+
66
+ return () => {
67
+ listeners.delete(listener);
68
+ if (listeners.size === 0) {
69
+ detachActiveQuery?.();
70
+ detachActiveQuery = null;
71
+ }
72
+ };
73
+ }
74
+
75
+ export function useReducedMotionAtSlot(slot: symbol): boolean {
76
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, slot);
77
+ }
78
+
79
+ export function useReducedMotion(): boolean;
80
+ export function useReducedMotion(...args: any[]): boolean {
81
+ // Calls from compiled `.tsrx` append the call-site slot. Accept no public
82
+ // arguments, but deliberately strip that compiler-owned trailing Symbol.
83
+ const tail = args[args.length - 1];
84
+ const slot = typeof tail === 'symbol' ? tail : undefined;
85
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, slot);
86
+ }