@voila.dev/motion 0.0.0-stage → 5.0.113

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.
Files changed (273) hide show
  1. package/README.md +105 -2
  2. package/dist/accessibility/reduced-motion.d.ts +13 -0
  3. package/dist/accessibility/reduced-motion.d.ts.map +1 -0
  4. package/dist/accessibility/reduced-motion.js +56 -0
  5. package/dist/accessibility/reduced-motion.js.map +7 -0
  6. package/dist/animate/animate.d.ts +21 -0
  7. package/dist/animate/animate.d.ts.map +1 -0
  8. package/dist/animate/animate.js +76 -0
  9. package/dist/animate/animate.js.map +7 -0
  10. package/dist/animate/controls.d.ts +16 -0
  11. package/dist/animate/controls.d.ts.map +1 -0
  12. package/dist/animate/controls.js +62 -0
  13. package/dist/animate/controls.js.map +7 -0
  14. package/dist/animate/dom.d.ts +11 -0
  15. package/dist/animate/dom.d.ts.map +1 -0
  16. package/dist/animate/dom.js +112 -0
  17. package/dist/animate/dom.js.map +7 -0
  18. package/dist/animate/js-animation.d.ts +25 -0
  19. package/dist/animate/js-animation.d.ts.map +1 -0
  20. package/dist/animate/js-animation.js +117 -0
  21. package/dist/animate/js-animation.js.map +7 -0
  22. package/dist/animate/plan.d.ts +17 -0
  23. package/dist/animate/plan.d.ts.map +1 -0
  24. package/dist/animate/plan.js +48 -0
  25. package/dist/animate/plan.js.map +7 -0
  26. package/dist/animate/style.d.ts +27 -0
  27. package/dist/animate/style.d.ts.map +1 -0
  28. package/dist/animate/style.js +96 -0
  29. package/dist/animate/style.js.map +7 -0
  30. package/dist/animate/types.d.ts +62 -0
  31. package/dist/animate/types.d.ts.map +1 -0
  32. package/dist/animate/types.js +1 -0
  33. package/dist/animate/types.js.map +7 -0
  34. package/dist/animate/value.d.ts +13 -0
  35. package/dist/animate/value.d.ts.map +1 -0
  36. package/dist/animate/value.js +71 -0
  37. package/dist/animate/value.js.map +7 -0
  38. package/dist/animate/waapi.d.ts +20 -0
  39. package/dist/animate/waapi.d.ts.map +1 -0
  40. package/dist/animate/waapi.js +152 -0
  41. package/dist/animate/waapi.js.map +7 -0
  42. package/dist/dom.d.ts +12 -0
  43. package/dist/dom.d.ts.map +1 -0
  44. package/dist/dom.js +17 -0
  45. package/dist/dom.js.map +7 -0
  46. package/dist/easing/cubic-bezier.d.ts +4 -0
  47. package/dist/easing/cubic-bezier.d.ts.map +1 -0
  48. package/dist/easing/cubic-bezier.js +54 -0
  49. package/dist/easing/cubic-bezier.js.map +7 -0
  50. package/dist/easing/linear-css.d.ts +9 -0
  51. package/dist/easing/linear-css.d.ts.map +1 -0
  52. package/dist/easing/linear-css.js +30 -0
  53. package/dist/easing/linear-css.js.map +7 -0
  54. package/dist/easing/named.d.ts +16 -0
  55. package/dist/easing/named.d.ts.map +1 -0
  56. package/dist/easing/named.js +82 -0
  57. package/dist/easing/named.js.map +7 -0
  58. package/dist/easing/spring.d.ts +11 -0
  59. package/dist/easing/spring.d.ts.map +1 -0
  60. package/dist/easing/spring.js +18 -0
  61. package/dist/easing/spring.js.map +7 -0
  62. package/dist/easing/steps.d.ts +4 -0
  63. package/dist/easing/steps.d.ts.map +1 -0
  64. package/dist/easing/steps.js +14 -0
  65. package/dist/easing/steps.js.map +7 -0
  66. package/dist/easing/types.d.ts +5 -0
  67. package/dist/easing/types.d.ts.map +1 -0
  68. package/dist/easing/types.js +1 -0
  69. package/dist/easing/types.js.map +7 -0
  70. package/dist/frame/driver.d.ts +13 -0
  71. package/dist/frame/driver.d.ts.map +1 -0
  72. package/dist/frame/driver.js +42 -0
  73. package/dist/frame/driver.js.map +7 -0
  74. package/dist/frame/frame.d.ts +20 -0
  75. package/dist/frame/frame.d.ts.map +1 -0
  76. package/dist/frame/frame.js +77 -0
  77. package/dist/frame/frame.js.map +7 -0
  78. package/dist/generators/inertia.d.ts +20 -0
  79. package/dist/generators/inertia.d.ts.map +1 -0
  80. package/dist/generators/inertia.js +59 -0
  81. package/dist/generators/inertia.js.map +7 -0
  82. package/dist/generators/spring.d.ts +17 -0
  83. package/dist/generators/spring.d.ts.map +1 -0
  84. package/dist/generators/spring.js +87 -0
  85. package/dist/generators/spring.js.map +7 -0
  86. package/dist/generators/tween.d.ts +15 -0
  87. package/dist/generators/tween.d.ts.map +1 -0
  88. package/dist/generators/tween.js +51 -0
  89. package/dist/generators/tween.js.map +7 -0
  90. package/dist/generators/types.d.ts +30 -0
  91. package/dist/generators/types.d.ts.map +1 -0
  92. package/dist/generators/types.js +1 -0
  93. package/dist/generators/types.js.map +7 -0
  94. package/dist/index.d.ts +26 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.js +97 -0
  97. package/dist/index.js.map +7 -0
  98. package/dist/interpolate/array.d.ts +3 -0
  99. package/dist/interpolate/array.d.ts.map +1 -0
  100. package/dist/interpolate/array.js +12 -0
  101. package/dist/interpolate/array.js.map +7 -0
  102. package/dist/interpolate/color-parse.d.ts +14 -0
  103. package/dist/interpolate/color-parse.d.ts.map +1 -0
  104. package/dist/interpolate/color-parse.js +111 -0
  105. package/dist/interpolate/color-parse.js.map +7 -0
  106. package/dist/interpolate/color.d.ts +11 -0
  107. package/dist/interpolate/color.d.ts.map +1 -0
  108. package/dist/interpolate/color.js +74 -0
  109. package/dist/interpolate/color.js.map +7 -0
  110. package/dist/interpolate/complex.d.ts +7 -0
  111. package/dist/interpolate/complex.d.ts.map +1 -0
  112. package/dist/interpolate/complex.js +56 -0
  113. package/dist/interpolate/complex.js.map +7 -0
  114. package/dist/interpolate/interpolate.d.ts +16 -0
  115. package/dist/interpolate/interpolate.d.ts.map +1 -0
  116. package/dist/interpolate/interpolate.js +39 -0
  117. package/dist/interpolate/interpolate.js.map +7 -0
  118. package/dist/interpolate/mix.d.ts +8 -0
  119. package/dist/interpolate/mix.d.ts.map +1 -0
  120. package/dist/interpolate/mix.js +48 -0
  121. package/dist/interpolate/mix.js.map +7 -0
  122. package/dist/interpolate/number.d.ts +2 -0
  123. package/dist/interpolate/number.d.ts.map +1 -0
  124. package/dist/interpolate/number.js +7 -0
  125. package/dist/interpolate/number.js.map +7 -0
  126. package/dist/interpolate/object.d.ts +5 -0
  127. package/dist/interpolate/object.d.ts.map +1 -0
  128. package/dist/interpolate/object.js +17 -0
  129. package/dist/interpolate/object.js.map +7 -0
  130. package/dist/interpolate/path/flatten.d.ts +10 -0
  131. package/dist/interpolate/path/flatten.d.ts.map +1 -0
  132. package/dist/interpolate/path/flatten.js +135 -0
  133. package/dist/interpolate/path/flatten.js.map +7 -0
  134. package/dist/interpolate/path/morph.d.ts +9 -0
  135. package/dist/interpolate/path/morph.d.ts.map +1 -0
  136. package/dist/interpolate/path/morph.js +72 -0
  137. package/dist/interpolate/path/morph.js.map +7 -0
  138. package/dist/interpolate/path/parse.d.ts +24 -0
  139. package/dist/interpolate/path/parse.d.ts.map +1 -0
  140. package/dist/interpolate/path/parse.js +125 -0
  141. package/dist/interpolate/path/parse.js.map +7 -0
  142. package/dist/interpolate/path/rings.d.ts +13 -0
  143. package/dist/interpolate/path/rings.d.ts.map +1 -0
  144. package/dist/interpolate/path/rings.js +117 -0
  145. package/dist/interpolate/path/rings.js.map +7 -0
  146. package/dist/react/index.d.ts +7 -0
  147. package/dist/react/index.d.ts.map +1 -0
  148. package/dist/react/index.js +19 -0
  149. package/dist/react/index.js.map +7 -0
  150. package/dist/react/presence.d.ts +27 -0
  151. package/dist/react/presence.d.ts.map +1 -0
  152. package/dist/react/presence.js +135 -0
  153. package/dist/react/presence.js.map +7 -0
  154. package/dist/react/use-animate.d.ts +17 -0
  155. package/dist/react/use-animate.d.ts.map +1 -0
  156. package/dist/react/use-animate.js +34 -0
  157. package/dist/react/use-animate.js.map +7 -0
  158. package/dist/react/use-motion-value.d.ts +4 -0
  159. package/dist/react/use-motion-value.d.ts.map +1 -0
  160. package/dist/react/use-motion-value.js +11 -0
  161. package/dist/react/use-motion-value.js.map +7 -0
  162. package/dist/react/use-reduced-motion.d.ts +7 -0
  163. package/dist/react/use-reduced-motion.d.ts.map +1 -0
  164. package/dist/react/use-reduced-motion.js +19 -0
  165. package/dist/react/use-reduced-motion.js.map +7 -0
  166. package/dist/react/use-spring.d.ts +9 -0
  167. package/dist/react/use-spring.d.ts.map +1 -0
  168. package/dist/react/use-spring.js +27 -0
  169. package/dist/react/use-spring.js.map +7 -0
  170. package/dist/react/use-transform.d.ts +13 -0
  171. package/dist/react/use-transform.d.ts.map +1 -0
  172. package/dist/react/use-transform.js +44 -0
  173. package/dist/react/use-transform.js.map +7 -0
  174. package/dist/sequence/sequence.d.ts +25 -0
  175. package/dist/sequence/sequence.d.ts.map +1 -0
  176. package/dist/sequence/sequence.js +68 -0
  177. package/dist/sequence/sequence.js.map +7 -0
  178. package/dist/stagger/stagger.d.ts +12 -0
  179. package/dist/stagger/stagger.d.ts.map +1 -0
  180. package/dist/stagger/stagger.js +21 -0
  181. package/dist/stagger/stagger.js.map +7 -0
  182. package/dist/testing/manual-frames.d.ts +11 -0
  183. package/dist/testing/manual-frames.d.ts.map +1 -0
  184. package/dist/testing/manual-frames.js +40 -0
  185. package/dist/testing/manual-frames.js.map +7 -0
  186. package/dist/value/motion-value.d.ts +27 -0
  187. package/dist/value/motion-value.d.ts.map +1 -0
  188. package/dist/value/motion-value.js +88 -0
  189. package/dist/value/motion-value.js.map +7 -0
  190. package/dist/value/transform.d.ts +16 -0
  191. package/dist/value/transform.d.ts.map +1 -0
  192. package/dist/value/transform.js +28 -0
  193. package/dist/value/transform.js.map +7 -0
  194. package/dist/view/in-view.d.ts +19 -0
  195. package/dist/view/in-view.d.ts.map +1 -0
  196. package/dist/view/in-view.js +46 -0
  197. package/dist/view/in-view.js.map +7 -0
  198. package/dist/view/resize.d.ts +13 -0
  199. package/dist/view/resize.d.ts.map +1 -0
  200. package/dist/view/resize.js +72 -0
  201. package/dist/view/resize.js.map +7 -0
  202. package/dist/view/resolve-elements.d.ts +3 -0
  203. package/dist/view/resolve-elements.d.ts.map +1 -0
  204. package/dist/view/resolve-elements.js +10 -0
  205. package/dist/view/resolve-elements.js.map +7 -0
  206. package/dist/view/scroll-measure.d.ts +16 -0
  207. package/dist/view/scroll-measure.d.ts.map +1 -0
  208. package/dist/view/scroll-measure.js +51 -0
  209. package/dist/view/scroll-measure.js.map +7 -0
  210. package/dist/view/scroll-offset.d.ts +28 -0
  211. package/dist/view/scroll-offset.d.ts.map +1 -0
  212. package/dist/view/scroll-offset.js +55 -0
  213. package/dist/view/scroll-offset.js.map +7 -0
  214. package/dist/view/scroll.d.ts +34 -0
  215. package/dist/view/scroll.d.ts.map +1 -0
  216. package/dist/view/scroll.js +83 -0
  217. package/dist/view/scroll.js.map +7 -0
  218. package/package.json +57 -5
  219. package/src/accessibility/reduced-motion.ts +75 -0
  220. package/src/animate/animate.ts +155 -0
  221. package/src/animate/controls.ts +76 -0
  222. package/src/animate/dom.ts +166 -0
  223. package/src/animate/js-animation.ts +151 -0
  224. package/src/animate/plan.ts +74 -0
  225. package/src/animate/style.ts +131 -0
  226. package/src/animate/types.ts +69 -0
  227. package/src/animate/value.ts +93 -0
  228. package/src/animate/waapi.ts +212 -0
  229. package/src/dom.ts +35 -0
  230. package/src/easing/cubic-bezier.ts +64 -0
  231. package/src/easing/linear-css.ts +41 -0
  232. package/src/easing/named.ts +88 -0
  233. package/src/easing/spring.ts +25 -0
  234. package/src/easing/steps.ts +16 -0
  235. package/src/easing/types.ts +18 -0
  236. package/src/frame/driver.ts +56 -0
  237. package/src/frame/frame.ts +102 -0
  238. package/src/generators/inertia.ts +88 -0
  239. package/src/generators/spring.ts +117 -0
  240. package/src/generators/tween.ts +74 -0
  241. package/src/generators/types.ts +31 -0
  242. package/src/index.ts +91 -0
  243. package/src/interpolate/array.ts +13 -0
  244. package/src/interpolate/color-parse.ts +136 -0
  245. package/src/interpolate/color.ts +97 -0
  246. package/src/interpolate/complex.ts +80 -0
  247. package/src/interpolate/interpolate.ts +60 -0
  248. package/src/interpolate/mix.ts +74 -0
  249. package/src/interpolate/number.ts +3 -0
  250. package/src/interpolate/object.ts +20 -0
  251. package/src/interpolate/path/flatten.ts +185 -0
  252. package/src/interpolate/path/morph.ts +100 -0
  253. package/src/interpolate/path/parse.ts +162 -0
  254. package/src/interpolate/path/rings.ts +151 -0
  255. package/src/react/index.ts +11 -0
  256. package/src/react/presence.tsx +194 -0
  257. package/src/react/use-animate.ts +46 -0
  258. package/src/react/use-motion-value.ts +9 -0
  259. package/src/react/use-reduced-motion.ts +22 -0
  260. package/src/react/use-spring.ts +35 -0
  261. package/src/react/use-transform.ts +73 -0
  262. package/src/sequence/sequence.ts +129 -0
  263. package/src/stagger/stagger.ts +36 -0
  264. package/src/testing/browser-commands.d.ts +11 -0
  265. package/src/testing/manual-frames.ts +49 -0
  266. package/src/value/motion-value.ts +131 -0
  267. package/src/value/transform.ts +61 -0
  268. package/src/view/in-view.ts +74 -0
  269. package/src/view/resize.ts +101 -0
  270. package/src/view/resolve-elements.ts +11 -0
  271. package/src/view/scroll-measure.ts +83 -0
  272. package/src/view/scroll-offset.ts +100 -0
  273. package/src/view/scroll.ts +143 -0
@@ -0,0 +1,73 @@
1
+ import * as React from "react";
2
+ import {
3
+ type InterpolateOptions,
4
+ interpolate,
5
+ } from "#/interpolate/interpolate.ts";
6
+ import { type MotionValue, motionValue } from "#/value/motion-value.ts";
7
+
8
+ /**
9
+ * A motion value derived from others: mapped from an input range to an
10
+ * output range, or computed by a function. It updates with its sources,
11
+ * without re-rendering the component.
12
+ */
13
+ export function useTransform<T>(
14
+ source: MotionValue<number>,
15
+ input: readonly number[],
16
+ output: readonly T[],
17
+ options?: InterpolateOptions<T>,
18
+ ): MotionValue<T>;
19
+ export function useTransform<S, T>(
20
+ source: MotionValue<S>,
21
+ compute: (value: S) => T,
22
+ ): MotionValue<T>;
23
+ export function useTransform<V extends readonly unknown[], T>(
24
+ sources: { readonly [K in keyof V]: MotionValue<V[K]> },
25
+ compute: (...values: V) => T,
26
+ ): MotionValue<T>;
27
+ export function useTransform(...args: unknown[]): MotionValue<unknown> {
28
+ const [source, second, output, options] = args;
29
+ const sources = (
30
+ Array.isArray(source) ? source : [source]
31
+ ) as MotionValue<unknown>[];
32
+ // Ranges are usually inline arrays: rebuild the mapping when their contents change, not their identity.
33
+ const ranges =
34
+ typeof second === "function" ? "" : JSON.stringify([second, output]);
35
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the ranges' contents.
36
+ const map = React.useMemo(
37
+ () =>
38
+ typeof second === "function"
39
+ ? undefined
40
+ : interpolate(
41
+ second as readonly number[],
42
+ output as readonly unknown[],
43
+ options as InterpolateOptions<unknown>,
44
+ ),
45
+ [ranges],
46
+ );
47
+ const compute = (map ?? second) as (...values: unknown[]) => unknown;
48
+ const latest = React.useRef(compute);
49
+ latest.current = compute;
50
+ function read(): unknown {
51
+ return latest.current(...sources.map((one) => one.get()));
52
+ }
53
+ // biome-ignore lint/correctness/useExhaustiveDependencies: rebuilt only when a source changes.
54
+ const derived = React.useMemo(() => motionValue(read()), sources);
55
+ // Rendered with new ranges or a new function: the value follows at once.
56
+ const computed = React.useRef(compute);
57
+ if (computed.current !== compute) {
58
+ computed.current = compute;
59
+ derived.set(read());
60
+ }
61
+ // Subscribed in an effect, so StrictMode's unmount and remount leave it listening.
62
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the sources are the identity of `derived`.
63
+ React.useEffect(() => {
64
+ derived.set(read());
65
+ const unsubscribes = sources.map((one) =>
66
+ one.on("change", () => derived.set(read())),
67
+ );
68
+ return () => {
69
+ for (const unsubscribe of unsubscribes) unsubscribe();
70
+ };
71
+ }, [derived]);
72
+ return derived;
73
+ }
@@ -0,0 +1,129 @@
1
+ import { groupControls, thenable } from "#/animate/controls.ts";
2
+ import type {
3
+ AnimationControls,
4
+ AnimationOptions,
5
+ DOMKeyframes,
6
+ ElementTarget,
7
+ Keyframes,
8
+ } from "#/animate/types.ts";
9
+ import { now } from "#/frame/frame.ts";
10
+ import type { MotionValue } from "#/value/motion-value.ts";
11
+
12
+ /**
13
+ * When a segment starts: seconds from the start of the sequence, `"+0.1"` or
14
+ * `"-0.2"` from the end of the segment before, `"<"` with the segment before,
15
+ * or the name of a label.
16
+ */
17
+ export type SequenceAt = number | `+${number}` | `-${number}` | "<" | string;
18
+
19
+ export interface SegmentOptions extends AnimationOptions {
20
+ readonly at?: SequenceAt;
21
+ }
22
+
23
+ export type Segment =
24
+ | readonly [ElementTarget, DOMKeyframes, SegmentOptions?]
25
+ // biome-ignore lint/suspicious/noExplicitAny: a sequence mixes values of any type.
26
+ | readonly [MotionValue<any>, Keyframes<any>, SegmentOptions?]
27
+ | string
28
+ | { readonly name: string; readonly at?: SequenceAt };
29
+
30
+ export type AnimateOne = (
31
+ target: unknown,
32
+ keyframes: unknown,
33
+ options: AnimationOptions,
34
+ ) => AnimationControls;
35
+
36
+ export type ResolveTargets = (target: unknown) => readonly object[];
37
+
38
+ function startOf(
39
+ at: SequenceAt | undefined,
40
+ cursor: number,
41
+ previousStart: number,
42
+ labels: ReadonlyMap<string, number>,
43
+ ): number {
44
+ if (at === undefined) return cursor;
45
+ if (typeof at === "number") return at;
46
+ if (at === "<") return previousStart;
47
+ if (/^[+-]/.test(at)) return Math.max(0, cursor + Number(at));
48
+ return labels.get(at) ?? cursor;
49
+ }
50
+
51
+ /**
52
+ * Launches every segment at once, each delayed to its place on the
53
+ * timeline, so the whole sequence shares one clock and can be scrubbed. A
54
+ * segment that animates a property an earlier segment already animates
55
+ * waits for that one to finish, then starts from where it left off.
56
+ */
57
+ export function animateSequence(
58
+ segments: readonly Segment[],
59
+ options: AnimationOptions,
60
+ animateOne: AnimateOne,
61
+ resolveTargets: ResolveTargets,
62
+ ): AnimationControls {
63
+ const begun = now();
64
+ const labels = new Map<string, number>();
65
+ const busy = new Map<object, Map<string, Promise<void>>>();
66
+ const children: AnimationControls[] = [];
67
+ const waits: Promise<void>[] = [];
68
+ let cursor = 0;
69
+ let previousStart = 0;
70
+ for (const segment of segments) {
71
+ if (typeof segment === "string" || "name" in segment) {
72
+ const label = typeof segment === "string" ? { name: segment } : segment;
73
+ labels.set(label.name, startOf(label.at, cursor, previousStart, labels));
74
+ continue;
75
+ }
76
+ const [target, keyframes, own = {}] = segment as readonly [
77
+ unknown,
78
+ unknown,
79
+ SegmentOptions?,
80
+ ];
81
+ const start = startOf(own.at, cursor, previousStart, labels);
82
+ const { delay } = own;
83
+ /** Launched late, a segment only waits for what is left of its delay. */
84
+ function optionsAt(elapsed: number): AnimationOptions {
85
+ const offset = start - elapsed;
86
+ return {
87
+ ...options,
88
+ ...own,
89
+ delay:
90
+ typeof delay === "function"
91
+ ? (index, total) => Math.max(0, offset + delay(index, total))
92
+ : Math.max(0, offset + (delay ?? 0)),
93
+ };
94
+ }
95
+ const keys =
96
+ typeof keyframes === "object" &&
97
+ keyframes !== null &&
98
+ !Array.isArray(keyframes)
99
+ ? Object.keys(keyframes)
100
+ : ["value"];
101
+ const targets = resolveTargets(target);
102
+ const before = targets.flatMap((one) =>
103
+ keys.flatMap((key) => busy.get(one)?.get(key) ?? []),
104
+ );
105
+ const launch = () =>
106
+ animateOne(target, keyframes, optionsAt((now() - begun) / 1000));
107
+ let finished: Promise<void>;
108
+ if (before.length === 0) {
109
+ const controls = launch();
110
+ children.push(controls);
111
+ finished = controls.finished;
112
+ cursor = Math.max(start, controls.duration);
113
+ } else {
114
+ finished = Promise.all(before).then(() => launch().finished);
115
+ waits.push(finished);
116
+ }
117
+ for (const one of targets) {
118
+ const byKey = busy.get(one) ?? new Map();
119
+ busy.set(one, byKey);
120
+ for (const key of keys) byKey.set(key, finished);
121
+ }
122
+ previousStart = start;
123
+ }
124
+ const group = groupControls(children);
125
+ const finished = Promise.all([group.finished, ...waits]).then(
126
+ () => undefined,
127
+ );
128
+ return Object.assign(group, thenable(finished));
129
+ }
@@ -0,0 +1,36 @@
1
+ import { resolveEasing } from "#/easing/named.ts";
2
+ import type { EasingDefinition } from "#/easing/types.ts";
3
+
4
+ export interface StaggerOptions {
5
+ /** Seconds before the first one starts. */
6
+ readonly startDelay?: number;
7
+ /** Which one starts first; the others follow by their distance from it. */
8
+ readonly from?: "first" | "last" | "center" | number;
9
+ /** Spreads the delays along an easing instead of evenly. */
10
+ readonly ease?: EasingDefinition;
11
+ }
12
+
13
+ const ORIGINS: Record<"first" | "last" | "center", (total: number) => number> =
14
+ {
15
+ first: () => 0,
16
+ last: (total) => total - 1,
17
+ center: (total) => (total - 1) / 2,
18
+ };
19
+
20
+ /** A `delay` for many targets: `gap` seconds between neighbours. */
21
+ export function stagger(
22
+ gap = 0.1,
23
+ options: StaggerOptions = {},
24
+ ): (index: number, total: number) => number {
25
+ const { startDelay = 0, from = "first", ease } = options;
26
+ return (index, total) => {
27
+ const origin = typeof from === "number" ? from : ORIGINS[from](total);
28
+ const distance = Math.abs(index - origin);
29
+ if (ease === undefined) return startDelay + distance * gap;
30
+ const furthest = Math.max(origin, total - 1 - origin);
31
+ const spread = furthest * gap;
32
+ return furthest === 0
33
+ ? startDelay
34
+ : startDelay + resolveEasing(ease)(distance / furthest) * spread;
35
+ };
36
+ }
@@ -0,0 +1,11 @@
1
+ import "vitest/browser";
2
+
3
+ declare module "vitest/browser" {
4
+ interface BrowserCommands {
5
+ emulateMedia(media: {
6
+ readonly colorScheme?: "light" | "dark" | null;
7
+ readonly forcedColors?: "active" | "none" | null;
8
+ readonly reducedMotion?: "reduce" | "no-preference" | null;
9
+ }): Promise<void>;
10
+ }
11
+ }
@@ -0,0 +1,49 @@
1
+ import { setFrameDriver } from "#/frame/driver.ts";
2
+ import { resetFrameLoop } from "#/frame/frame.ts";
3
+
4
+ const FRAME = 1000 / 60;
5
+
6
+ export interface ManualFrames {
7
+ /** Moves the clock by `ms`, running each 60 Hz frame it crosses while one is requested. */
8
+ advance(ms: number): void;
9
+ /** The clock, in milliseconds. */
10
+ time(): number;
11
+ /** Back to the browser's frames, with an empty loop. */
12
+ restore(): void;
13
+ }
14
+
15
+ /** Installs a clock the test steps by hand, starting at 0 with an empty loop. */
16
+ export function manualFrames(): ManualFrames {
17
+ let time = 0;
18
+ let tick: ((timestamp: number) => void) | undefined;
19
+ resetFrameLoop();
20
+ setFrameDriver({
21
+ request(next) {
22
+ tick = next;
23
+ },
24
+ cancel() {
25
+ tick = undefined;
26
+ },
27
+ now: () => time,
28
+ });
29
+ return {
30
+ advance(ms) {
31
+ const end = time + ms;
32
+ // Frames land on the 60 Hz grid however the test slices its steps.
33
+ let frameAt = (Math.floor(time / FRAME + 1e-9) + 1) * FRAME;
34
+ while (frameAt <= end + 1e-9) {
35
+ time = frameAt;
36
+ const next = tick;
37
+ tick = undefined;
38
+ next?.(time);
39
+ frameAt += FRAME;
40
+ }
41
+ time = end;
42
+ },
43
+ time: () => time,
44
+ restore() {
45
+ resetFrameLoop();
46
+ setFrameDriver(undefined);
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,131 @@
1
+ import { now } from "#/frame/frame.ts";
2
+
3
+ export type MotionValueEvent =
4
+ | "change"
5
+ | "animationStart"
6
+ | "animationComplete"
7
+ | "animationCancel";
8
+
9
+ /** What `animate()` hands a value it drives; `finished` lets the value announce the end. */
10
+ export interface MotionValueAnimation {
11
+ stop(): void;
12
+ readonly finished?: PromiseLike<unknown>;
13
+ }
14
+
15
+ export interface MotionValue<T = number> {
16
+ get(): T;
17
+ /** Sets the value and tracks its velocity. */
18
+ set(value: T): void;
19
+ /** Sets the value with no velocity and stops any animation. */
20
+ jump(value: T): void;
21
+ /** Units per second; 0 for a non-number or a value left alone for 30 ms. */
22
+ getVelocity(): number;
23
+ getPrevious(): T | undefined;
24
+ on(event: "change", listener: (value: T) => void): () => void;
25
+ on(
26
+ event: Exclude<MotionValueEvent, "change">,
27
+ listener: () => void,
28
+ ): () => void;
29
+ /** Stops the running animation (`animationCancel`), then `animationStart`. */
30
+ start(animation: MotionValueAnimation): void;
31
+ /** Stops the running animation, if any, with `animationCancel`. */
32
+ stop(): void;
33
+ isAnimating(): boolean;
34
+ destroy(): void;
35
+ }
36
+
37
+ const STALE_AFTER = 30;
38
+ const values = new WeakSet<object>();
39
+
40
+ export function isMotionValue(value: unknown): value is MotionValue<unknown> {
41
+ return typeof value === "object" && value !== null && values.has(value);
42
+ }
43
+
44
+ export function motionValue<T>(initial: T): MotionValue<T> {
45
+ let current = initial;
46
+ let updatedAt = now();
47
+ let last: T | undefined;
48
+ let previous: T | undefined;
49
+ let previousAt = updatedAt;
50
+ let animation: MotionValueAnimation | undefined;
51
+ const listeners = new Map<MotionValueEvent, Set<(value?: T) => void>>();
52
+
53
+ function emit(event: MotionValueEvent): void {
54
+ for (const listener of listeners.get(event) ?? []) listener(current);
55
+ }
56
+
57
+ function stop(): void {
58
+ const running = animation;
59
+ if (!running) return;
60
+ // Cleared first: the animation's own stop may call back into this value.
61
+ animation = undefined;
62
+ running.stop();
63
+ emit("animationCancel");
64
+ }
65
+
66
+ const value: MotionValue<T> = {
67
+ get: () => current,
68
+ set(next) {
69
+ const time = now();
70
+ // Two sets in one frame keep the previous frame's sample, so velocity
71
+ // spans real time instead of dividing by zero.
72
+ if (time !== updatedAt) {
73
+ previous = current;
74
+ previousAt = updatedAt;
75
+ }
76
+ last = current;
77
+ current = next;
78
+ updatedAt = time;
79
+ emit("change");
80
+ },
81
+ jump(next) {
82
+ stop();
83
+ last = current;
84
+ current = next;
85
+ previous = undefined;
86
+ updatedAt = now();
87
+ previousAt = updatedAt;
88
+ emit("change");
89
+ },
90
+ getVelocity() {
91
+ const elapsed = updatedAt - previousAt;
92
+ if (
93
+ typeof current !== "number" ||
94
+ typeof previous !== "number" ||
95
+ elapsed <= 0 ||
96
+ now() - updatedAt > STALE_AFTER
97
+ ) {
98
+ return 0;
99
+ }
100
+ return ((current - previous) / elapsed) * 1000;
101
+ },
102
+ getPrevious: () => last,
103
+ // One implementation behind both overloads: a lifecycle listener ignores the argument.
104
+ on: ((event: MotionValueEvent, listener: (value?: T) => void) => {
105
+ const set = listeners.get(event) ?? new Set();
106
+ listeners.set(event, set);
107
+ set.add(listener);
108
+ return () => {
109
+ set.delete(listener);
110
+ };
111
+ }) as MotionValue<T>["on"],
112
+ start(next) {
113
+ stop();
114
+ animation = next;
115
+ emit("animationStart");
116
+ next.finished?.then(() => {
117
+ if (animation !== next) return;
118
+ animation = undefined;
119
+ emit("animationComplete");
120
+ });
121
+ },
122
+ stop,
123
+ isAnimating: () => animation !== undefined,
124
+ destroy() {
125
+ stop();
126
+ listeners.clear();
127
+ },
128
+ };
129
+ values.add(value);
130
+ return value;
131
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ type InterpolateOptions,
3
+ interpolate,
4
+ } from "#/interpolate/interpolate.ts";
5
+ import { type MotionValue, motionValue } from "#/value/motion-value.ts";
6
+
7
+ type Values<V extends readonly unknown[]> = {
8
+ readonly [K in keyof V]: MotionValue<V[K]>;
9
+ };
10
+
11
+ /**
12
+ * A value derived from others, recomputed on each of their changes:
13
+ * `transformValue(x, [0, 100], [1, 0])` maps through a range,
14
+ * `transformValue([x, y], (x, y) => Math.hypot(x, y))` combines. Its
15
+ * `destroy()` lets go of the sources.
16
+ */
17
+ export function transformValue<T>(
18
+ source: MotionValue<number>,
19
+ input: readonly number[],
20
+ output: readonly T[],
21
+ options?: InterpolateOptions<T>,
22
+ ): MotionValue<T>;
23
+ export function transformValue<S, T>(
24
+ source: MotionValue<S>,
25
+ compute: (value: S) => T,
26
+ ): MotionValue<T>;
27
+ export function transformValue<V extends readonly unknown[], T>(
28
+ sources: Values<V>,
29
+ compute: (...values: V) => T,
30
+ ): MotionValue<T>;
31
+ export function transformValue<T>(
32
+ sources: MotionValue<unknown> | readonly MotionValue<unknown>[],
33
+ inputOrCompute: readonly number[] | ((...values: never[]) => T),
34
+ output?: readonly T[],
35
+ options?: InterpolateOptions<T>,
36
+ ): MotionValue<T> {
37
+ const list: readonly MotionValue<unknown>[] = Array.isArray(sources)
38
+ ? sources
39
+ : [sources as MotionValue<unknown>];
40
+ const compute =
41
+ typeof inputOrCompute === "function"
42
+ ? (inputOrCompute as (...values: unknown[]) => T)
43
+ : mapThrough(interpolate(inputOrCompute, output ?? [], options));
44
+ function read(): T {
45
+ return compute(...list.map((source) => source.get()));
46
+ }
47
+ const derived = motionValue(read());
48
+ const unsubscribes = list.map((source) =>
49
+ source.on("change", () => derived.set(read())),
50
+ );
51
+ const destroy = derived.destroy;
52
+ derived.destroy = () => {
53
+ for (const unsubscribe of unsubscribes) unsubscribe();
54
+ destroy();
55
+ };
56
+ return derived;
57
+ }
58
+
59
+ function mapThrough<T>(map: (value: number) => T): (...values: unknown[]) => T {
60
+ return (value) => map(value as number);
61
+ }
@@ -0,0 +1,74 @@
1
+ import { resolveElements } from "#/view/resolve-elements.ts";
2
+
3
+ export type InViewAmount = "some" | "all" | number;
4
+
5
+ export interface InViewOptions {
6
+ /** The scrolling ancestor to intersect with; the viewport by default. */
7
+ readonly root?: Element | Document;
8
+ /** Grows or shrinks the root's box, CSS margin syntax: "0px 0px -20% 0px". */
9
+ readonly margin?: string;
10
+ /** How much of the element must show: any pixel, all of it, or a fraction. */
11
+ readonly amount?: InViewAmount;
12
+ }
13
+
14
+ export type OnViewEnd = (entry: IntersectionObserverEntry) => void;
15
+ export type OnViewStart = (
16
+ element: Element,
17
+ entry: IntersectionObserverEntry,
18
+ ) => undefined | OnViewEnd;
19
+
20
+ const THRESHOLDS: Readonly<Record<"some" | "all", number>> = {
21
+ some: 0,
22
+ all: 1,
23
+ };
24
+
25
+ /**
26
+ * Calls `onStart` when an element comes into view. When it returns a
27
+ * function, that runs when the element leaves and `onStart` runs again on
28
+ * the next entry; otherwise the element fires once. Returns a function that
29
+ * stops observing.
30
+ */
31
+ export function inView(
32
+ target: Element | string | readonly Element[],
33
+ onStart: OnViewStart,
34
+ options: InViewOptions = {},
35
+ ): () => void {
36
+ const elements = resolveElements(target);
37
+ if (elements.length === 0 || typeof IntersectionObserver === "undefined") {
38
+ return () => {};
39
+ }
40
+ const amount = options.amount ?? "some";
41
+ const threshold = typeof amount === "number" ? amount : THRESHOLDS[amount];
42
+ const active = new Map<Element, OnViewEnd>();
43
+
44
+ function onIntersect(entries: IntersectionObserverEntry[]) {
45
+ for (const entry of entries) {
46
+ // Chromium reports a fully visible box as 0.99… on fractional layouts.
47
+ const visible =
48
+ entry.isIntersecting && entry.intersectionRatio + 1e-3 >= threshold;
49
+ const onEnd = active.get(entry.target);
50
+ if (visible && onEnd === undefined) {
51
+ const next = onStart(entry.target, entry);
52
+ if (typeof next === "function") {
53
+ active.set(entry.target, next);
54
+ } else {
55
+ observer.unobserve(entry.target);
56
+ }
57
+ }
58
+ if (!visible && onEnd !== undefined) {
59
+ active.delete(entry.target);
60
+ onEnd(entry);
61
+ }
62
+ }
63
+ }
64
+
65
+ const observer = new IntersectionObserver(onIntersect, {
66
+ root: options.root,
67
+ rootMargin: options.margin,
68
+ threshold,
69
+ });
70
+ for (const element of elements) {
71
+ observer.observe(element);
72
+ }
73
+ return () => observer.disconnect();
74
+ }
@@ -0,0 +1,101 @@
1
+ import { resolveElements } from "#/view/resolve-elements.ts";
2
+
3
+ export interface Size {
4
+ readonly width: number;
5
+ readonly height: number;
6
+ }
7
+
8
+ export type OnElementResize = (element: Element, size: Size) => void;
9
+ export type OnWindowResize = (size: Size) => void;
10
+
11
+ // One observer for every element on the page: a ResizeObserver per call
12
+ // would multiply the browser's work by the number of animated elements.
13
+ let observer: ResizeObserver | undefined;
14
+ const elementListeners = new Map<Element, Set<OnElementResize>>();
15
+ const windowListeners = new Set<OnWindowResize>();
16
+
17
+ function boxSize(entry: ResizeObserverEntry): Size {
18
+ const box = entry.borderBoxSize?.[0];
19
+ return box
20
+ ? { width: box.inlineSize, height: box.blockSize }
21
+ : { width: entry.contentRect.width, height: entry.contentRect.height };
22
+ }
23
+
24
+ function notifyElements(entries: ResizeObserverEntry[]) {
25
+ for (const entry of entries) {
26
+ const size = boxSize(entry);
27
+ for (const listener of elementListeners.get(entry.target) ?? []) {
28
+ listener(entry.target, size);
29
+ }
30
+ }
31
+ }
32
+
33
+ function notifyWindow() {
34
+ const size = { width: window.innerWidth, height: window.innerHeight };
35
+ for (const listener of windowListeners) {
36
+ listener(size);
37
+ }
38
+ }
39
+
40
+ function resizeWindow(onResize: OnWindowResize): () => void {
41
+ if (windowListeners.size === 0) {
42
+ window.addEventListener("resize", notifyWindow);
43
+ }
44
+ windowListeners.add(onResize);
45
+ return () => {
46
+ windowListeners.delete(onResize);
47
+ if (windowListeners.size === 0) {
48
+ window.removeEventListener("resize", notifyWindow);
49
+ }
50
+ };
51
+ }
52
+
53
+ function resizeElements(
54
+ target: Element | string | readonly Element[],
55
+ onResize: OnElementResize,
56
+ ): () => void {
57
+ if (typeof ResizeObserver === "undefined") {
58
+ return () => {};
59
+ }
60
+ observer ??= new ResizeObserver(notifyElements);
61
+ const elements = resolveElements(target);
62
+ for (const element of elements) {
63
+ const listeners = elementListeners.get(element) ?? new Set();
64
+ if (listeners.size === 0) {
65
+ elementListeners.set(element, listeners);
66
+ observer.observe(element);
67
+ }
68
+ listeners.add(onResize);
69
+ }
70
+ return () => {
71
+ for (const element of elements) {
72
+ const listeners = elementListeners.get(element);
73
+ listeners?.delete(onResize);
74
+ if (listeners?.size === 0) {
75
+ elementListeners.delete(element);
76
+ observer?.unobserve(element);
77
+ }
78
+ }
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Calls back when an element's border box changes size, or when the window
84
+ * does. Returns a function that stops listening.
85
+ */
86
+ export function resize(onResize: OnWindowResize): () => void;
87
+ export function resize(
88
+ target: Element | string | readonly Element[],
89
+ onResize: OnElementResize,
90
+ ): () => void;
91
+ export function resize(
92
+ targetOrListener: Element | string | readonly Element[] | OnWindowResize,
93
+ onResize?: OnElementResize,
94
+ ): () => void {
95
+ if (typeof targetOrListener === "function") {
96
+ return typeof window === "undefined"
97
+ ? () => {}
98
+ : resizeWindow(targetOrListener);
99
+ }
100
+ return onResize ? resizeElements(targetOrListener, onResize) : () => {};
101
+ }
@@ -0,0 +1,11 @@
1
+ /** Elements named by a selector, a list or a single element. */
2
+ export function resolveElements(
3
+ target: Element | string | readonly Element[],
4
+ ): readonly Element[] {
5
+ if (typeof target === "string") {
6
+ return typeof document === "undefined"
7
+ ? []
8
+ : Array.from(document.querySelectorAll(target));
9
+ }
10
+ return "length" in target ? target : [target as Element];
11
+ }