motion-library 0.0.0-stage → 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.
Files changed (146) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +37 -2
  3. package/anims/basics/anticipation.js +56 -0
  4. package/anims/basics/card-flip.js +39 -0
  5. package/anims/basics/easing-race.js +57 -0
  6. package/anims/basics/fade-scale.js +44 -0
  7. package/anims/basics/stagger-grid.js +43 -0
  8. package/anims/chrome/black-hole.js +81 -0
  9. package/anims/chrome/cloth-reveal.js +156 -0
  10. package/anims/chrome/dna-helix.js +52 -0
  11. package/anims/chrome/dust-bust.js +109 -0
  12. package/anims/chrome/extruded-type.js +64 -0
  13. package/anims/chrome/fractal-tree.js +75 -0
  14. package/anims/chrome/gem.js +111 -0
  15. package/anims/chrome/geode.js +102 -0
  16. package/anims/chrome/glass-lens.js +73 -0
  17. package/anims/chrome/glass-prism.js +95 -0
  18. package/anims/chrome/gyroid.js +69 -0
  19. package/anims/chrome/holo-card.js +52 -0
  20. package/anims/chrome/infinite-tunnel.js +81 -0
  21. package/anims/chrome/inflated-type.js +141 -0
  22. package/anims/chrome/isometric-city.js +97 -0
  23. package/anims/chrome/jelly-cube.js +329 -0
  24. package/anims/chrome/klein-bottle.js +80 -0
  25. package/anims/chrome/liquid-metal.js +94 -0
  26. package/anims/chrome/low-poly-planet.js +163 -0
  27. package/anims/chrome/mandelbulb.js +79 -0
  28. package/anims/chrome/melting-logo.js +126 -0
  29. package/anims/chrome/menger-sponge.js +79 -0
  30. package/anims/chrome/neon-geodesic.js +149 -0
  31. package/anims/chrome/paper-fold.js +135 -0
  32. package/anims/chrome/path-spheres.js +112 -0
  33. package/anims/chrome/point-globe.js +81 -0
  34. package/anims/chrome/portal.js +105 -0
  35. package/anims/chrome/rubiks-cube.js +74 -0
  36. package/anims/chrome/sdf-morph.js +129 -0
  37. package/anims/chrome/shatter.js +120 -0
  38. package/anims/chrome/soap-bubble.js +86 -0
  39. package/anims/chrome/terrain-flyover.js +78 -0
  40. package/anims/chrome/tesseract.js +148 -0
  41. package/anims/chrome/torus-knot.js +89 -0
  42. package/anims/chrome/volumetric-clouds.js +70 -0
  43. package/anims/flow/bio-shore.js +140 -0
  44. package/anims/flow/calligraphy.js +116 -0
  45. package/anims/flow/chladni.js +152 -0
  46. package/anims/flow/cloud-chamber.js +101 -0
  47. package/anims/flow/confetti.js +46 -0
  48. package/anims/flow/curl-flow.js +52 -0
  49. package/anims/flow/dandelion.js +80 -0
  50. package/anims/flow/dla-crystal.js +57 -0
  51. package/anims/flow/drone-show.js +357 -0
  52. package/anims/flow/embers.js +63 -0
  53. package/anims/flow/firefly-sync.js +107 -0
  54. package/anims/flow/galaxy.js +69 -0
  55. package/anims/flow/galton-board.js +155 -0
  56. package/anims/flow/gpu-particles.js +73 -0
  57. package/anims/flow/harmonograph.js +61 -0
  58. package/anims/flow/holi.js +153 -0
  59. package/anims/flow/iron-filings.js +112 -0
  60. package/anims/flow/lightning.js +72 -0
  61. package/anims/flow/murmuration.js +105 -0
  62. package/anims/flow/orbitals.js +195 -0
  63. package/anims/flow/particle-life.js +87 -0
  64. package/anims/flow/physarum.js +171 -0
  65. package/anims/flow/pin-art.js +181 -0
  66. package/anims/flow/rainbow.js +127 -0
  67. package/anims/flow/roche-limit.js +161 -0
  68. package/anims/flow/sand-mandala.js +121 -0
  69. package/anims/flow/scramble-crossing.js +152 -0
  70. package/anims/flow/snowfall.js +84 -0
  71. package/anims/flow/sparks.js +71 -0
  72. package/anims/flow/spider-web.js +123 -0
  73. package/anims/flow/starry-night.js +121 -0
  74. package/anims/flow/states-of-matter.js +113 -0
  75. package/anims/flow/strange-attractor.js +54 -0
  76. package/anims/flow/wheat-field.js +186 -0
  77. package/anims/flow/wind-tunnel.js +227 -0
  78. package/anims/fluid/bubbles.js +152 -0
  79. package/anims/fluid/caustics.js +45 -0
  80. package/anims/fluid/dripping-text.js +53 -0
  81. package/anims/fluid/ferrofluid.js +56 -0
  82. package/anims/fluid/fire-smoke.js +83 -0
  83. package/anims/fluid/honey-drip.js +167 -0
  84. package/anims/fluid/ink-drop.js +50 -0
  85. package/anims/fluid/ink-fluid.js +125 -0
  86. package/anims/fluid/lava-lamp.js +56 -0
  87. package/anims/fluid/liquid-distort.js +64 -0
  88. package/anims/fluid/liquid-glass-fill.js +53 -0
  89. package/anims/fluid/marbling.js +53 -0
  90. package/anims/fluid/mercury.js +177 -0
  91. package/anims/fluid/ocean-waves.js +50 -0
  92. package/anims/fluid/paint-swirl.js +153 -0
  93. package/anims/fluid/raindrops-glass.js +60 -0
  94. package/anims/fluid/ripples.js +57 -0
  95. package/anims/fluid/sph-water.js +75 -0
  96. package/anims/fluid/underwater.js +55 -0
  97. package/anims/fluid/water-surface-3d.js +98 -0
  98. package/anims/hover/cursor-trail.js +37 -0
  99. package/anims/hover/magnetic.js +41 -0
  100. package/anims/hover/ripple.js +31 -0
  101. package/anims/hover/spotlight.js +31 -0
  102. package/anims/hover/tilt.js +36 -0
  103. package/anims/micro/arc-spinner.js +22 -0
  104. package/anims/micro/like-burst.js +42 -0
  105. package/anims/micro/skeleton.js +36 -0
  106. package/anims/micro/submit-success.js +45 -0
  107. package/anims/micro/toggle.js +35 -0
  108. package/anims/morph/gooey.js +36 -0
  109. package/anims/morph/icon-morph.js +45 -0
  110. package/anims/morph/line-draw.js +34 -0
  111. package/anims/morph/liquid-button.js +53 -0
  112. package/anims/morph/shape-morph.js +61 -0
  113. package/anims/registry-more.js +327 -0
  114. package/anims/registry.js +294 -0
  115. package/anims/scroll/parallax.js +50 -0
  116. package/anims/scroll/pinned-track.js +40 -0
  117. package/anims/scroll/scrub-reveal.js +34 -0
  118. package/anims/scroll/velocity-skew.js +38 -0
  119. package/anims/scroll/zoom-through.js +41 -0
  120. package/anims/shaders/aurora.js +47 -0
  121. package/anims/shaders/dither.js +69 -0
  122. package/anims/shaders/domain-warp.js +52 -0
  123. package/anims/shaders/guilloche.js +42 -0
  124. package/anims/shaders/kaleidoscope.js +64 -0
  125. package/anims/shaders/reaction-diffusion.js +85 -0
  126. package/anims/shaders/rgb-glitch.js +56 -0
  127. package/anims/shaders/truchet.js +57 -0
  128. package/anims/shaders/voronoi.js +50 -0
  129. package/anims/springs/ball-collisions.js +47 -0
  130. package/anims/springs/pendulum-wave.js +35 -0
  131. package/anims/springs/rubber-band.js +50 -0
  132. package/anims/springs/squash-bounce.js +50 -0
  133. package/anims/springs/verlet-rope.js +46 -0
  134. package/anims/text/letter-rise.js +40 -0
  135. package/anims/text/line-mask.js +26 -0
  136. package/anims/text/scramble.js +32 -0
  137. package/anims/text/typewriter.js +35 -0
  138. package/anims/text/vf-wave.js +26 -0
  139. package/anims/transitions/circle-reveal.js +42 -0
  140. package/anims/transitions/push-slide.js +40 -0
  141. package/anims/transitions/shared-expand.js +43 -0
  142. package/anims/transitions/staircase-wipe.js +41 -0
  143. package/anims/transitions/view-transition.js +43 -0
  144. package/bin.mjs +155 -0
  145. package/js/code.js +122 -0
  146. package/package.json +10 -4
@@ -0,0 +1,50 @@
1
+ // Squash & stretch bounce: stretch along the velocity in flight, squash on impact while keeping the area.
2
+ const PARAMS = { gravity: 2400, bounce: 0.72, squash: 0.35 };
3
+
4
+ export default function (stage) {
5
+ const canvas = document.createElement('canvas');
6
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
7
+ stage.append(canvas);
8
+ const ctx = canvas.getContext('2d');
9
+ let W = 0, H = 0;
10
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth; H = stage.clientHeight; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
11
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
12
+
13
+ const wob = (t, z = 0.3, w = 22) => t <= 0 ? 0 : Math.exp(-z * w * t) * Math.sin(w * Math.sqrt(1 - z * z) * t);
14
+ let y = 0, vy = 0, x = 0, impactT = 1e9, impactV = 0, restUntil = 0, last = performance.now(), raf;
15
+ const reset = () => { y = H * 0.12; vy = 0; x = W * 0.5; restUntil = 0; };
16
+ reset();
17
+ function frame(now) {
18
+ const dt = Math.min(0.032, (now - last) / 1000); last = now;
19
+ const r = Math.min(W, H) * 0.09, floor = H * 0.8;
20
+ const g = PARAMS.gravity * (H / 600);
21
+ impactT += dt;
22
+ if (restUntil) { if (now > restUntil) reset(); }
23
+ else {
24
+ vy += g * dt; y += vy * dt;
25
+ if (y > floor - r) {
26
+ y = floor - r; impactV = Math.abs(vy); vy = -vy * PARAMS.bounce; impactT = 0;
27
+ if (Math.abs(vy) < g * 0.06) { vy = 0; restUntil = now + 600; } // settled: rest, then drop again
28
+ }
29
+ }
30
+ // stretch with speed while airborne; squash after impact, strength by impact speed
31
+ const speed = Math.abs(vy) / (g * 0.5);
32
+ let sy = 1 + Math.min(0.35, speed * 0.25);
33
+ const hit = Math.min(1, impactV / (g * 0.45)) * PARAMS.squash;
34
+ if (impactT < 0.6) sy = 1 - hit * wob(impactT + 0.07) * 1.4;
35
+ sy = Math.max(0.4, sy); const sx = 1 / sy;
36
+
37
+ ctx.fillStyle = '#fff4e6'; ctx.fillRect(0, 0, W, H);
38
+ ctx.fillStyle = '#ffd8a8'; ctx.fillRect(0, floor, W, H - floor);
39
+ const near = 1 - Math.min(1, (floor - r - y) / (floor * 0.8));
40
+ ctx.fillStyle = `rgba(140, 60, 0, ${0.08 + 0.22 * near})`;
41
+ ctx.beginPath(); ctx.ellipse(x, floor + 1, r * (0.5 + 0.7 * near) * sx, r * 0.18, 0, 0, Math.PI * 2); ctx.fill();
42
+ ctx.save(); ctx.translate(x, y + r); ctx.scale(sx, sy);
43
+ ctx.fillStyle = '#d9601a'; ctx.beginPath(); ctx.arc(0, -r, r, 0, Math.PI * 2); ctx.fill();
44
+ ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.beginPath(); ctx.arc(-r * 0.35, -r * 1.35, r * 0.28, 0, Math.PI * 2); ctx.fill();
45
+ ctx.restore();
46
+ raf = requestAnimationFrame(frame);
47
+ }
48
+ raf = requestAnimationFrame(frame);
49
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
50
+ }
@@ -0,0 +1,46 @@
1
+ // Verlet rope: each point moves by its last motion plus gravity, then neighbours are pulled back to rest length.
2
+ const PARAMS = { segments: 24, gravity: 900, iterations: 8 };
3
+
4
+ export default function (stage) {
5
+ const canvas = document.createElement('canvas');
6
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
7
+ stage.append(canvas);
8
+ const ctx = canvas.getContext('2d');
9
+ let W = 0, H = 0;
10
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth; H = stage.clientHeight; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
11
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
12
+
13
+ let px = W / 2, py = H * 0.2, lastMove = -1e9;
14
+ const onMove = e => { const b = canvas.getBoundingClientRect(); px = e.clientX - b.left; py = e.clientY - b.top; lastMove = performance.now(); };
15
+ canvas.addEventListener('pointermove', onMove);
16
+ const n = Math.round(PARAMS.segments), rest = (H * 0.62) / n;
17
+ const pts = Array.from({ length: n + 1 }, (_, i) => ({ x: W / 2, y: H * 0.2 + i * rest, ox: W / 2, oy: H * 0.2 + i * rest }));
18
+ let last = performance.now(), raf;
19
+ function frame(now) {
20
+ const dt = Math.min(0.025, (now - last) / 1000); last = now;
21
+ let ax = px, ay = py;
22
+ if (now - lastMove > 1500) { const t = now / 1000; ax = W / 2 + Math.sin(t * 1.3) * W * 0.3; ay = H * 0.18 + Math.sin(t * 2.6) * H * 0.06; }
23
+ pts[0].x = ax; pts[0].y = ay;
24
+ for (let i = 1; i <= n; i++) {
25
+ const p = pts[i], vx = (p.x - p.ox) * 0.995, vy = (p.y - p.oy) * 0.995;
26
+ p.ox = p.x; p.oy = p.y; p.x += vx; p.y += vy + PARAMS.gravity * dt * dt;
27
+ }
28
+ for (let k = 0; k < PARAMS.iterations; k++) {
29
+ for (let i = 0; i < n; i++) {
30
+ const a = pts[i], b = pts[i + 1], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1, diff = (d - rest) / d;
31
+ if (i === 0) { b.x -= dx * diff; b.y -= dy * diff; }
32
+ else { a.x += dx * diff * 0.5; a.y += dy * diff * 0.5; b.x -= dx * diff * 0.5; b.y -= dy * diff * 0.5; }
33
+ }
34
+ }
35
+ ctx.fillStyle = '#fff4e6'; ctx.fillRect(0, 0, W, H);
36
+ ctx.strokeStyle = '#d9601a'; ctx.lineWidth = Math.max(3, W * 0.012); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
37
+ ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
38
+ for (let i = 1; i < n; i++) { const mx = (pts[i].x + pts[i + 1].x) / 2, my = (pts[i].y + pts[i + 1].y) / 2; ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my); }
39
+ ctx.lineTo(pts[n].x, pts[n].y); ctx.stroke();
40
+ ctx.fillStyle = '#15171a'; ctx.beginPath(); ctx.arc(pts[0].x, pts[0].y, 6, 0, Math.PI * 2); ctx.fill();
41
+ ctx.fillStyle = '#d9601a'; ctx.beginPath(); ctx.arc(pts[n].x, pts[n].y, Math.max(9, W * 0.035), 0, Math.PI * 2); ctx.fill();
42
+ raf = requestAnimationFrame(frame);
43
+ }
44
+ raf = requestAnimationFrame(frame);
45
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); canvas.remove(); };
46
+ }
@@ -0,0 +1,40 @@
1
+ // Letter rise stagger: every letter rises out of a clipped line on its own spring, 40 ms after the last.
2
+ const PARAMS = { stagger: 40, stiffness: 220, damping: 18 };
3
+
4
+ export default function (stage) {
5
+ const words = ['Motion', 'Rhythm', 'Timing', 'Weight'];
6
+ const root = document.createElement('div');
7
+ root.className = 'lr-root';
8
+ root.innerHTML = `<style>
9
+ .lr-root { position: absolute; inset: 0; display: grid; place-items: center; background: #d9363e; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
10
+ .lr-line { display: flex; overflow: hidden; font-size: 24cqmin; line-height: 1.12; font-weight: 800; font-stretch: 110%; color: #fff5f5; letter-spacing: -0.02em; padding: 0 .05em; }
11
+ .lr-line span { display: block; will-change: transform; }
12
+ </style><div class="lr-line"></div>`;
13
+ stage.append(root);
14
+ const line = root.querySelector('.lr-line');
15
+ let letters = [], w = 0, raf, last = performance.now(), timers = [];
16
+
17
+ function setWord(text) {
18
+ line.replaceChildren(); letters = [...text].map(ch => { const s = document.createElement('span'); s.textContent = ch; line.append(s); return { el: s, y: 110, v: 0, target: 110 }; });
19
+ }
20
+ function rise() {
21
+ setWord(words[w++ % words.length]);
22
+ letters.forEach((l, i) => timers.push(setTimeout(() => { l.target = 0; }, i * PARAMS.stagger)));
23
+ timers.push(setTimeout(fall, 1500 + letters.length * PARAMS.stagger));
24
+ }
25
+ function fall() {
26
+ letters.forEach((l, i) => timers.push(setTimeout(() => { l.target = -115; }, i * PARAMS.stagger * 0.6)));
27
+ timers.push(setTimeout(rise, 420 + letters.length * PARAMS.stagger * 0.6));
28
+ }
29
+ function frame(now) {
30
+ const dt = Math.min(0.032, (now - last) / 1000); last = now;
31
+ for (const l of letters) {
32
+ for (let s = 0; s < 3; s++) { const h = dt / 3; l.v += (-PARAMS.stiffness * (l.y - l.target) - PARAMS.damping * l.v) * h; l.y += l.v * h; }
33
+ l.el.style.transform = `translateY(${l.y}%)`;
34
+ }
35
+ raf = requestAnimationFrame(frame);
36
+ }
37
+ rise();
38
+ raf = requestAnimationFrame(frame);
39
+ return () => { cancelAnimationFrame(raf); timers.forEach(clearTimeout); root.remove(); };
40
+ }
@@ -0,0 +1,26 @@
1
+ // Line mask reveal: each line sits in an overflow-hidden box and slides up into view, one after another.
2
+ const PARAMS = { stagger: 90, duration: 700 };
3
+
4
+ export default function (stage) {
5
+ const lines = ['Good motion', 'is felt before', 'it is noticed.'];
6
+ const root = document.createElement('div');
7
+ root.className = 'lm-root';
8
+ root.innerHTML = `<style>
9
+ .lm-root { position: absolute; inset: 0; display: grid; align-content: center; padding: 0 10cqmin; background: #2b1214; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
10
+ .lm-mask { overflow: hidden; }
11
+ .lm-mask span { display: block; font-size: 12cqmin; line-height: 1.08; font-weight: 750; letter-spacing: -0.025em; color: #ffe3e3; transform: translateY(105%); }
12
+ .lm-mask:nth-child(2) span { color: #ff8787; }
13
+ </style>`;
14
+ const spans = lines.map(t => { const m = document.createElement('div'); m.className = 'lm-mask'; m.innerHTML = `<span>${t}</span>`; root.append(m); return m.firstChild; });
15
+ stage.append(root);
16
+ const ease = 'cubic-bezier(0.23, 1, 0.32, 1)';
17
+ let timers = [];
18
+ function cycle() {
19
+ spans.forEach((s, i) => s.animate([{ transform: 'translateY(105%)' }, { transform: 'translateY(0)' }], { duration: PARAMS.duration, delay: i * PARAMS.stagger, easing: ease, fill: 'forwards' }));
20
+ const shown = PARAMS.duration + spans.length * PARAMS.stagger + 1400;
21
+ timers.push(setTimeout(() => spans.forEach((s, i) => s.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-105%)' }], { duration: PARAMS.duration * 0.6, delay: i * PARAMS.stagger * 0.5, easing: 'cubic-bezier(0.7, 0, 0.84, 0)', fill: 'forwards' })), shown));
22
+ timers.push(setTimeout(cycle, shown + PARAMS.duration * 0.6 + spans.length * PARAMS.stagger * 0.5 + 300));
23
+ }
24
+ cycle();
25
+ return () => { timers.forEach(clearTimeout); root.remove(); };
26
+ }
@@ -0,0 +1,32 @@
1
+ // Scramble decode: each character cycles through random glyphs until its turn, then locks, left to right.
2
+ const PARAMS = { speed: 34, settle: 4 };
3
+
4
+ export default function (stage) {
5
+ const phrases = ['DECODING SIGNAL', 'MOTION LIBRARY', 'COPY THE CODE', 'SIXTY EFFECTS'];
6
+ const glyphs = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/';
7
+ const root = document.createElement('div');
8
+ root.className = 'sc-root';
9
+ root.innerHTML = `<style>
10
+ .sc-root { position: absolute; inset: 0; display: grid; place-items: center; background: #15171a; container-type: size; }
11
+ .sc-text { font: 700 8.4cqmin/1 ui-monospace, "JetBrains Mono", Menlo, monospace; color: #f1f3f5; letter-spacing: .04em; white-space: pre; }
12
+ .sc-text b { color: #ff6b6b; font-weight: 700; }
13
+ </style><div class="sc-text"></div>`;
14
+ stage.append(root);
15
+ const out = root.querySelector('.sc-text');
16
+ let p = 0, step = 0, target = phrases[0], timer, pause = 0;
17
+
18
+ timer = setInterval(() => {
19
+ if (pause > 0) { pause--; if (pause === 0) { p = (p + 1) % phrases.length; target = phrases[p]; step = 0; } return; }
20
+ step++;
21
+ let html = '', done = true;
22
+ for (let i = 0; i < target.length; i++) {
23
+ const lockAt = (i + 1) * PARAMS.settle;
24
+ if (target[i] === ' ') html += ' ';
25
+ else if (step >= lockAt) html += target[i];
26
+ else { done = false; html += `<b>${glyphs[Math.floor(Math.random() * glyphs.length)]}</b>`; }
27
+ }
28
+ out.innerHTML = html;
29
+ if (done) pause = Math.round(1400 / PARAMS.speed);
30
+ }, PARAMS.speed);
31
+ return () => { clearInterval(timer); root.remove(); };
32
+ }
@@ -0,0 +1,35 @@
1
+ // Typewriter: jittered keystrokes, longer pauses on punctuation, a caret that only blinks while idle.
2
+ const PARAMS = { cps: 16, jitter: 0.5 };
3
+
4
+ export default function (stage) {
5
+ const lines = ['Hello, I animate things.', 'Ease out, never in.', 'Every frame is a decision.'];
6
+ const root = document.createElement('div');
7
+ root.className = 'tw-root';
8
+ root.innerHTML = `<style>
9
+ .tw-root { position: absolute; inset: 0; display: grid; place-items: center; background: #f8f9fa; container-type: size; padding: 0 8cqmin; }
10
+ .tw-line { font-family: "Mona Sans", system-ui, sans-serif; font-size: 9cqmin; font-weight: 650; color: #15171a; letter-spacing: -0.01em; }
11
+ .tw-caret { display: inline-block; width: .08em; height: 1em; margin-left: .06em; vertical-align: -0.12em; background: #d9363e; }
12
+ .tw-idle .tw-caret { animation: tw-blink 1s steps(1) infinite; }
13
+ @keyframes tw-blink { 50% { opacity: 0; } }
14
+ </style><div class="tw-line"><span class="tw-text"></span><span class="tw-caret"></span></div>`;
15
+ stage.append(root);
16
+ const text = root.querySelector('.tw-text'), line = root.querySelector('.tw-line');
17
+ let li = 0, timer;
18
+ const delay = ch => {
19
+ const base = 1000 / PARAMS.cps, j = 1 + (Math.random() * 2 - 1) * PARAMS.jitter;
20
+ return base * j + (/[,.!?]/.test(ch) ? base * 6 : 0);
21
+ };
22
+ function type(i = 0) {
23
+ const s = lines[li];
24
+ line.classList.remove('tw-idle');
25
+ if (i <= s.length) { text.textContent = s.slice(0, i); timer = setTimeout(() => type(i + 1), delay(s[i - 1] || '')); }
26
+ else { line.classList.add('tw-idle'); timer = setTimeout(() => erase(s.length), 1600); }
27
+ }
28
+ function erase(i) {
29
+ line.classList.remove('tw-idle');
30
+ if (i >= 0) { text.textContent = lines[li].slice(0, i); timer = setTimeout(() => erase(i - 1), 22); }
31
+ else { li = (li + 1) % lines.length; line.classList.add('tw-idle'); timer = setTimeout(() => type(0), 500); }
32
+ }
33
+ type(0);
34
+ return () => { clearTimeout(timer); root.remove(); };
35
+ }
@@ -0,0 +1,26 @@
1
+ // Variable-font wave: a sine wave travels through each letter's weight and width axes.
2
+ // Needs a variable font with wght and wdth axes (Mona Sans here).
3
+ const PARAMS = { amplitude: 1, speed: 1.6, spread: 0.5 };
4
+
5
+ export default function (stage) {
6
+ const root = document.createElement('div');
7
+ root.className = 'vw-root';
8
+ root.innerHTML = `<style>
9
+ .vw-root { position: absolute; inset: 0; display: grid; place-items: center; background: #fff0f0; container-type: size; }
10
+ .vw-word { display: flex; font-family: "Mona Sans", system-ui, sans-serif; font-size: 17cqmin; line-height: 1; color: #c92a2a; }
11
+ </style><div class="vw-word"></div>`;
12
+ stage.append(root);
13
+ const word = root.querySelector('.vw-word');
14
+ const spans = [...'wavelength'].map(ch => { const s = document.createElement('span'); s.textContent = ch; word.append(s); return s; });
15
+ let raf;
16
+ function frame(now) {
17
+ const t = now / 1000 * PARAMS.speed;
18
+ spans.forEach((s, i) => {
19
+ const k = 0.5 + 0.5 * Math.sin(t * 2 - i * PARAMS.spread) * PARAMS.amplitude;
20
+ s.style.fontVariationSettings = `'wght' ${Math.round(200 + 700 * k)}, 'wdth' ${(75 + 50 * k).toFixed(1)}`;
21
+ });
22
+ raf = requestAnimationFrame(frame);
23
+ }
24
+ raf = requestAnimationFrame(frame);
25
+ return () => { cancelAnimationFrame(raf); root.remove(); };
26
+ }
@@ -0,0 +1,42 @@
1
+ // Circle reveal: the next page is clipped to a circle that grows from the press point.
2
+ const PARAMS = { duration: 800 };
3
+
4
+ export default function (stage) {
5
+ const pages = [
6
+ { bg: '#0e9488', fg: '#e6fcf5', word: 'Ocean' },
7
+ { bg: '#ffd43b', fg: '#15171a', word: 'Sun' },
8
+ { bg: '#15171a', fg: '#f8f9fa', word: 'Night' },
9
+ { bg: '#ff8787', fg: '#2b0b0b', word: 'Coral' },
10
+ ];
11
+ const root = document.createElement('div');
12
+ root.className = 'cr-root';
13
+ root.innerHTML = `<style>
14
+ .cr-root { position: absolute; inset: 0; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; cursor: crosshair; }
15
+ .cr-page { position: absolute; inset: 0; display: grid; place-items: center; font-size: 24cqmin; font-weight: 800; letter-spacing: -0.03em; }
16
+ </style><div class="cr-page"></div><div class="cr-page"></div>`;
17
+ stage.append(root);
18
+ const [under, over] = root.querySelectorAll('.cr-page');
19
+ let i = 0, busy = false;
20
+ const paint = (el, p) => { el.style.background = p.bg; el.style.color = p.fg; el.textContent = p.word; };
21
+ paint(under, pages[0]);
22
+ over.style.clipPath = 'circle(0px at 50% 50%)';
23
+
24
+ function reveal(x, y) {
25
+ if (busy) return; busy = true;
26
+ const next = pages[(i + 1) % pages.length];
27
+ paint(over, next);
28
+ const w = root.clientWidth, h = root.clientHeight;
29
+ const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
30
+ const a = over.animate([{ clipPath: `circle(0px at ${x}px ${y}px)` }, { clipPath: `circle(${r}px at ${x}px ${y}px)` }],
31
+ { duration: PARAMS.duration, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' });
32
+ a.onfinish = () => { i = (i + 1) % pages.length; paint(under, next); a.cancel(); over.style.clipPath = 'circle(0px at 50% 50%)'; busy = false; };
33
+ }
34
+ const onDown = e => { const b = root.getBoundingClientRect(); reveal(e.clientX - b.left, e.clientY - b.top); manual = performance.now(); };
35
+ let manual = -1e9;
36
+ root.addEventListener('pointerdown', onDown);
37
+ const auto = setInterval(() => {
38
+ if (performance.now() - manual < 3000) return;
39
+ reveal(root.clientWidth * (0.2 + Math.random() * 0.6), root.clientHeight * (0.2 + Math.random() * 0.6));
40
+ }, PARAMS.duration + 900);
41
+ return () => { clearInterval(auto); root.removeEventListener('pointerdown', onDown); root.remove(); };
42
+ }
@@ -0,0 +1,40 @@
1
+ // Push slide: the new page slides the full width while the old one drifts only part of the way and dims.
2
+ const PARAMS = { duration: 700, parallax: 0.3 };
3
+
4
+ export default function (stage) {
5
+ const pages = [
6
+ { name: 'Home', bg: '#0e9488', fg: '#e6fcf5', shape: '#63e6be' },
7
+ { name: 'Work', bg: '#15171a', fg: '#f1f3f5', shape: '#ffd43b' },
8
+ { name: 'About', bg: '#e6fcf5', fg: '#0b5f59', shape: '#0e9488' },
9
+ ];
10
+ const root = document.createElement('div');
11
+ root.className = 'ps-root';
12
+ root.innerHTML = `<style>
13
+ .ps-root { position: absolute; inset: 0; overflow: hidden; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; background: #000; }
14
+ .ps-page { position: absolute; inset: 0; padding: 8cqmin 9cqmin; box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; }
15
+ .ps-page nav { display: flex; gap: 4cqmin; font-size: max(11px, 4.4cqmin); font-weight: 600; opacity: .8; }
16
+ .ps-page h2 { margin: 0; font-size: 22cqmin; line-height: .9; font-weight: 800; font-stretch: 115%; letter-spacing: -0.03em; }
17
+ .ps-page i { position: absolute; right: 9cqmin; top: 26%; width: 30cqmin; height: 30cqmin; border-radius: 50%; }
18
+ </style>`;
19
+ const els = pages.map(p => {
20
+ const el = document.createElement('section'); el.className = 'ps-page';
21
+ el.style.cssText = `background:${p.bg};color:${p.fg}`;
22
+ el.innerHTML = `<nav><span>Home</span><span>Work</span><span>About</span></nav><i style="background:${p.shape}"></i><h2>${p.name}</h2>`;
23
+ root.append(el); return el;
24
+ });
25
+ stage.append(root);
26
+ els.forEach((el, i) => { el.style.transform = i === 0 ? 'none' : 'translateX(100%)'; });
27
+
28
+ const ease = 'cubic-bezier(0.77, 0, 0.175, 1)';
29
+ let cur = 0;
30
+ const go = () => {
31
+ const next = (cur + 1) % els.length, a = els[cur], b = els[next];
32
+ b.style.zIndex = 2; a.style.zIndex = 1;
33
+ const opts = { duration: PARAMS.duration, easing: ease, fill: 'forwards' };
34
+ a.animate([{ transform: 'none', filter: 'brightness(1)' }, { transform: `translateX(${-100 * PARAMS.parallax}%)`, filter: 'brightness(0.55)' }], opts);
35
+ b.animate([{ transform: 'translateX(100%)' }, { transform: 'none' }], opts);
36
+ cur = next;
37
+ };
38
+ const timer = setInterval(go, PARAMS.duration + 1000);
39
+ return () => { clearInterval(timer); root.remove(); };
40
+ }
@@ -0,0 +1,43 @@
1
+ // Shared-element expand (FLIP): measure First, jump to Last, Invert with a transform, Play it back to zero.
2
+ const PARAMS = { duration: 560 };
3
+
4
+ export default function (stage) {
5
+ const tiles = [['#0e9488', 'Lagoon'], ['#ffd43b', 'Dune'], ['#ff8787', 'Coral'], ['#4dabf7', 'Glacier'], ['#15171a', 'Basalt'], ['#a9e34b', 'Moss']];
6
+ const root = document.createElement('div');
7
+ root.className = 'fx-root';
8
+ root.innerHTML = `<style>
9
+ .fx-root { position: absolute; inset: 0; background: #f1f3f5; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
10
+ .fx-grid { position: absolute; inset: 6cqmin; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); gap: 3cqmin; }
11
+ .fx-tile { border-radius: 3cqmin; transform-origin: 0 0; display: flex; align-items: flex-end; padding: 3cqmin; box-sizing: border-box; font-weight: 700; font-size: max(10px, 4.5cqmin); color: rgba(0,0,0,.72); }
12
+ .fx-tile.dark { color: #fff; }
13
+ .fx-tile.open { position: absolute; inset: 6cqmin; z-index: 2; font-size: 11cqmin; }
14
+ </style><div class="fx-grid"></div>`;
15
+ stage.append(root);
16
+ const grid = root.querySelector('.fx-grid');
17
+ const els = tiles.map(([c, name], i) => { const el = document.createElement('div'); el.className = 'fx-tile' + (i === 4 ? ' dark' : ''); el.style.background = c; el.textContent = name; grid.append(el); return el; });
18
+ // The placeholder keeps the grid from collapsing while a tile is lifted out.
19
+ const hole = document.createElement('div');
20
+
21
+ function flip(el, change) {
22
+ const first = el.getBoundingClientRect();
23
+ change();
24
+ const last = el.getBoundingClientRect();
25
+ const dx = first.left - last.left, dy = first.top - last.top, sx = first.width / last.width, sy = first.height / last.height;
26
+ el.animate([{ transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, { transform: 'none' }],
27
+ { duration: PARAMS.duration, easing: 'cubic-bezier(0.32, 0.72, 0, 1)' });
28
+ }
29
+ let i = 0, open = null, timers = [];
30
+ function step() {
31
+ if (open) {
32
+ const el = open; open = null;
33
+ flip(el, () => { el.classList.remove('open'); hole.replaceWith(el); });
34
+ timers.push(setTimeout(step, PARAMS.duration + 500));
35
+ } else {
36
+ const el = els[i++ % els.length]; open = el;
37
+ flip(el, () => { el.replaceWith(hole); grid.append(el); el.classList.add('open'); hole.style.cssText = ''; });
38
+ timers.push(setTimeout(step, PARAMS.duration + 1100));
39
+ }
40
+ }
41
+ timers.push(setTimeout(step, 600));
42
+ return () => { timers.forEach(clearTimeout); root.remove(); };
43
+ }
@@ -0,0 +1,41 @@
1
+ // Staircase wipe: bars cover the screen one after another, the page swaps while hidden, the bars run out.
2
+ const PARAMS = { bars: 6, stagger: 60, duration: 500 };
3
+
4
+ export default function (stage) {
5
+ const pages = [
6
+ { bg: '#e6fcf5', fg: '#0b5f59', t: 'Chapter one' },
7
+ { bg: '#fff4e6', fg: '#a3400c', t: 'Chapter two' },
8
+ { bg: '#edf2ff', fg: '#2b3fa0', t: 'Chapter three' },
9
+ ];
10
+ const root = document.createElement('div');
11
+ root.className = 'sw-root';
12
+ root.innerHTML = `<style>
13
+ .sw-root { position: absolute; inset: 0; overflow: hidden; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
14
+ .sw-page { position: absolute; inset: 0; display: grid; align-content: end; padding: 9cqmin; font-size: 13cqmin; font-weight: 800; line-height: .95; letter-spacing: -0.02em; }
15
+ .sw-bars { position: absolute; inset: 0; display: flex; pointer-events: none; }
16
+ .sw-bars i { flex: 1; background: #0e9488; transform: translateY(-101%); margin-right: -1px; }
17
+ </style><div class="sw-page"></div><div class="sw-bars"></div>`;
18
+ stage.append(root);
19
+ const page = root.querySelector('.sw-page'), barsEl = root.querySelector('.sw-bars');
20
+ const n = Math.round(PARAMS.bars);
21
+ const bars = Array.from({ length: n }, () => barsEl.appendChild(document.createElement('i')));
22
+ let k = 0;
23
+ const paint = p => { page.style.background = p.bg; page.style.color = p.fg; page.textContent = p.t; };
24
+ paint(pages[0]);
25
+
26
+ const ease = 'cubic-bezier(0.76, 0, 0.24, 1)';
27
+ let timers = [];
28
+ function wipe() {
29
+ bars.forEach((b, i) => b.animate([{ transform: 'translateY(-101%)' }, { transform: 'translateY(0)' }],
30
+ { duration: PARAMS.duration, delay: i * PARAMS.stagger, easing: ease, fill: 'forwards' }));
31
+ const covered = PARAMS.duration + (n - 1) * PARAMS.stagger;
32
+ timers.push(setTimeout(() => {
33
+ k = (k + 1) % pages.length; paint(pages[k]);
34
+ bars.forEach((b, i) => b.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(101%)' }],
35
+ { duration: PARAMS.duration, delay: i * PARAMS.stagger, easing: ease, fill: 'forwards' }));
36
+ }, covered + 80));
37
+ timers.push(setTimeout(wipe, covered * 2 + 1300));
38
+ }
39
+ timers.push(setTimeout(wipe, 500));
40
+ return () => { timers.forEach(clearTimeout); root.remove(); };
41
+ }
@@ -0,0 +1,43 @@
1
+ // View Transitions morph: the browser animates every element whose view-transition-name matches before and after.
2
+ const PARAMS = { duration: 500 };
3
+
4
+ export default function (stage) {
5
+ const items = [['#0e9488', 'Lagoon', 'Shallow water over white sand, 2 m deep.'], ['#f59f00', 'Dune', 'Wind-shaped ridges that move a metre a year.'], ['#e8590c', 'Canyon', 'Layers of sandstone cut by a slow river.']];
6
+ const root = document.createElement('div');
7
+ root.className = 'vt-root';
8
+ root.innerHTML = `<style>
9
+ .vt-root { position: absolute; inset: 0; background: #fff; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; color: #15171a; overflow: hidden; }
10
+ .vt-list { padding: 7cqmin; display: grid; gap: 3cqmin; }
11
+ .vt-row { display: flex; align-items: center; gap: 4cqmin; font-size: max(12px, 6cqmin); font-weight: 650; }
12
+ .vt-sw { width: 13cqmin; height: 13cqmin; border-radius: 3cqmin; flex: none; }
13
+ .vt-detail .vt-sw { width: 100%; height: 48cqh; border-radius: 0; }
14
+ .vt-detail h3 { margin: 5cqmin 7cqmin 1cqmin; font-size: 11cqmin; letter-spacing: -0.02em; }
15
+ .vt-detail p { margin: 0 7cqmin; font-size: max(11px, 4.6cqmin); color: #5d6166; }
16
+ </style><div class="vt-view"></div><style class="vt-dur"></style>`;
17
+ stage.append(root);
18
+ const view = root.querySelector('.vt-view');
19
+ root.querySelector('.vt-dur').textContent = `::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation-duration: ${PARAMS.duration}ms; animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); }`;
20
+
21
+ let k = 0, detail = false;
22
+ function render() {
23
+ if (detail) {
24
+ const [c, name, text] = items[k];
25
+ view.className = 'vt-view vt-detail';
26
+ view.innerHTML = `<div class="vt-sw" style="background:${c};view-transition-name:vt-sw"></div><h3 style="view-transition-name:vt-title;width:fit-content">${name}</h3><p>${text}</p>`;
27
+ } else {
28
+ view.className = 'vt-view vt-list';
29
+ view.innerHTML = items.map(([c, name], i) => `<div class="vt-row"><div class="vt-sw" style="background:${c};${i === k ? 'view-transition-name:vt-sw' : ''}"></div><span style="${i === k ? 'view-transition-name:vt-title' : ''}">${name}</span></div>`).join('');
30
+ }
31
+ }
32
+ render();
33
+ // Open a row, close it back into the same row, then open the next one.
34
+ let opens = 0;
35
+ const change = () => { detail = !detail; render(); };
36
+ const timer = setInterval(() => {
37
+ // Before opening the next row, move the names onto it (no visible change) so the morph starts there.
38
+ if (!detail && opens++) { k = (k + 1) % items.length; render(); }
39
+ if (document.startViewTransition) document.startViewTransition(change);
40
+ else change(); // no support: swap instantly
41
+ }, PARAMS.duration + 1200);
42
+ return () => { clearInterval(timer); root.remove(); };
43
+ }
package/bin.mjs ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // motion-library: copy an animation from the Motion Library into your project, tuned to your numbers.
3
+ // npx motion-library add confetti --pieces 300
4
+ // The animations and js/code.js ship inside the package (cli/build.mjs copies them in), so nothing is downloaded.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { ANIMS, animById, catById, CATEGORIES, srcOf } from './anims/registry.js';
9
+ import { readParams, writeParams, parseParams, formatValue, standalone, promptFor } from './js/code.js';
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8'));
13
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
14
+ const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
15
+ const bold = s => c(1, s), dim = s => c(2, s), green = s => c(32, s), yellow = s => c(33, s);
16
+
17
+ const HELP = `${bold('motion-library')} ${pkg.version}: hand-made web animations, one plain-JS file each, no dependencies.
18
+
19
+ ${bold('Usage')}
20
+ npx motion-library add <id> [--<setting> <value> ...] [--dir <folder>] [--html] [--force]
21
+ npx motion-library list [category]
22
+ npx motion-library info <id>
23
+ npx motion-library prompt <id> [--<setting> <value> ...]
24
+
25
+ ${bold('Commands')}
26
+ add Copy an animation into your project. Settings you pass are written into its PARAMS line.
27
+ --dir where to put it (default: src/animations if there is a src folder, else animations)
28
+ --html write a standalone .html page instead of a .js module
29
+ --force overwrite a file that is already there
30
+ list All animations, or one category: ${CATEGORIES.map(k => k.id).join(', ')}
31
+ info What it does, how it works, and the settings you can tune
32
+ prompt Print a prompt for an AI coding assistant to fit the animation into your project
33
+
34
+ ${bold('Example')}
35
+ npx motion-library add confetti --pieces 300 --gravity 0.6
36
+
37
+ Browse them all, live: run the Motion Library site and open any card; Share gives a link with your settings.
38
+ Licence: MIT-0. Use it anywhere, change it freely, no credit needed.`;
39
+
40
+ // --pieces 300 or --pieces=300; flags without a value are booleans
41
+ function parseArgs(argv) {
42
+ const pos = [], opts = {};
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (!a.startsWith('--')) { pos.push(a); continue; }
46
+ const [k, v] = a.slice(2).split(/=(.*)/s);
47
+ if (v !== undefined) opts[k] = v;
48
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) opts[k] = argv[++i];
49
+ else opts[k] = true;
50
+ }
51
+ return { pos, opts };
52
+ }
53
+
54
+ function fail(msg) { console.error(`${c(31, 'error')} ${msg}`); process.exit(1); }
55
+
56
+ // edit distance, for "did you mean"
57
+ function typo(a, b) {
58
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
59
+ for (let i = 1; i <= a.length; i++) {
60
+ const cur = [i];
61
+ for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
62
+ prev = cur;
63
+ }
64
+ return prev[b.length];
65
+ }
66
+
67
+ function findAnim(id) {
68
+ if (!id) fail('Which animation? Try `npx motion-library list`.');
69
+ const a = animById[id];
70
+ if (a) return a;
71
+ const q = id.toLowerCase();
72
+ const near = ANIMS.map(x => [x, x.id.includes(q) || q.includes(x.id) || x.title.toLowerCase().includes(q) ? 0 : typo(q, x.id)])
73
+ .filter(([, d]) => d <= Math.max(2, Math.floor(q.length / 4))).sort((m, n) => m[1] - n[1]).slice(0, 5).map(([x]) => x);
74
+ fail(`No animation called "${id}".${near.length ? ` Did you mean: ${near.map(x => x.id).join(', ')}?` : ' Try `npx motion-library list`.'}`);
75
+ }
76
+
77
+ // the settings from the command line, through the same gate the site uses: only the animation's own settings, only
78
+ // numbers, clamped to the range and snapped to the step. Reports anything that was dropped or changed.
79
+ const OWN = new Set(['dir', 'html', 'force', 'help', 'version']);
80
+ function tuned(a, opts) {
81
+ const given = Object.entries(opts).filter(([k]) => !OWN.has(k));
82
+ const query = new URLSearchParams(given.map(([k, v]) => [k, String(v)])).toString();
83
+ const values = parseParams(query, a.params);
84
+ for (const [k, v] of given) {
85
+ if (!(k in a.params)) console.error(yellow(` ignored --${k}: ${a.id} has no setting called "${k}"${Object.keys(a.params).length ? ` (it has: ${Object.keys(a.params).join(', ')})` : ''}`));
86
+ else if (!(k in values)) console.error(yellow(` ignored --${k}: "${v}" is not a number`));
87
+ else if (Number(v) !== values[k]) console.error(yellow(` --${k} ${v} became ${values[k]} (range ${a.params[k].min}–${a.params[k].max}, step ${a.params[k].step})`));
88
+ }
89
+ return values;
90
+ }
91
+ const source = a => fs.readFileSync(path.join(here, srcOf(a)), 'utf8');
92
+ const header = a => `// ${a.title} · Motion Library (npx motion-library add ${a.id}) · MIT-0\n`;
93
+
94
+ function add(id, opts) {
95
+ const a = findAnim(id);
96
+ const values = tuned(a, opts);
97
+ let code = writeParams(source(a), { ...readParams(source(a)), ...values }, a.params);
98
+ const dir = typeof opts.dir === 'string' ? opts.dir : fs.existsSync('src') ? path.join('src', 'animations') : 'animations';
99
+ const file = path.join(dir, `${a.id}.${opts.html ? 'html' : 'js'}`);
100
+ if (fs.existsSync(file) && !opts.force) fail(`${file} is already there. Use --force to overwrite it.`);
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ fs.writeFileSync(file, opts.html ? standalone(code, a.title) : header(a) + code);
103
+ const p = readParams(code);
104
+ console.log(`${green('✓')} ${bold(a.title)} → ${file}`);
105
+ if (Object.keys(p).length) console.log(dim(` settings: ${Object.entries(p).map(([k, v]) => `${k} ${v}`).join(', ')}`));
106
+ if (opts.html) { console.log(dim(' open it in a browser: it runs on its own.')); return; }
107
+ const rel = './' + path.relative(process.cwd(), file).split(path.sep).join('/');
108
+ const name = a.id.replace(/-(\w)/g, (_, ch) => ch.toUpperCase());
109
+ console.log(`
110
+ ${bold('Use it')}: the default export draws into a container, sizes itself to it, and returns a cleanup function.
111
+ import ${name} from '${rel}'; // path from your project root: adjust it from your file
112
+ const stop = ${name}(document.querySelector('#hero')); // give the container a size
113
+ // later, e.g. when a component unmounts: stop();
114
+ ${a.pointer ? dim(' It reacts to the pointer inside its container.\n') : ''}${a.scroll ? dim(' It reads scroll inside its container (an element with data-scroll).\n') : ''}${dim(` Fit it to your project with an AI assistant: npx motion-library prompt ${a.id}`)}`);
115
+ }
116
+
117
+ function list(cat) {
118
+ if (cat && !catById[cat]) fail(`No category "${cat}". Categories: ${CATEGORIES.map(k => k.id).join(', ')}`);
119
+ for (const k of CATEGORIES) {
120
+ if (cat && k.id !== cat) continue;
121
+ const items = ANIMS.filter(a => a.cat === k.id);
122
+ console.log(`\n${bold(k.name)} ${dim(`(${k.id}, ${items.length})`)}`);
123
+ const w = Math.max(...items.map(a => a.id.length));
124
+ for (const a of items) console.log(` ${a.id.padEnd(w)} ${dim(a.title)}`);
125
+ }
126
+ console.log(dim(`\n${cat ? ANIMS.filter(a => a.cat === cat).length : ANIMS.length} animations. Add one: npx motion-library add <id>`));
127
+ }
128
+
129
+ function info(id) {
130
+ const a = findAnim(id), now = readParams(source(a));
131
+ console.log(`${bold(a.title)} ${dim(`· ${catById[a.cat].name} · ${a.kind}`)}\n${a.trick}\n`);
132
+ for (const n of a.notes) console.log(` • ${n}`);
133
+ const keys = Object.keys(a.params).filter(k => k in now);
134
+ if (keys.length) {
135
+ console.log(`\n${bold('Settings')}`);
136
+ const w = Math.max(...keys.map(k => k.length));
137
+ for (const k of keys) { const r = a.params[k]; console.log(` --${k.padEnd(w)} ${formatValue(now[k], r.step).padStart(6)} ${dim(`${r.label}, ${r.min}–${r.max}, step ${r.step}`)}`); }
138
+ }
139
+ console.log(dim(`\nnpx motion-library add ${a.id}${keys[0] ? ` --${keys[0]} ${a.params[keys[0]].max}` : ''}`));
140
+ }
141
+
142
+ function prompt(id, opts) {
143
+ const a = findAnim(id), values = tuned(a, opts);
144
+ const code = writeParams(source(a), { ...readParams(source(a)), ...values }, a.params);
145
+ process.stdout.write(promptFor(a, code, catById[a.cat].name));
146
+ }
147
+
148
+ const { pos: [cmd, arg], opts } = parseArgs(process.argv.slice(2));
149
+ if (opts.version || cmd === 'version') console.log(pkg.version);
150
+ else if (!cmd || cmd === 'help' || opts.help) console.log(HELP);
151
+ else if (cmd === 'add') add(arg, opts);
152
+ else if (cmd === 'list' || cmd === 'ls') list(arg);
153
+ else if (cmd === 'info') info(arg);
154
+ else if (cmd === 'prompt') prompt(arg, opts);
155
+ else fail(`Unknown command "${cmd}". Run \`npx motion-library help\`.`);