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.
- package/LICENSE +16 -0
- package/README.md +37 -2
- package/anims/basics/anticipation.js +56 -0
- package/anims/basics/card-flip.js +39 -0
- package/anims/basics/easing-race.js +57 -0
- package/anims/basics/fade-scale.js +44 -0
- package/anims/basics/stagger-grid.js +43 -0
- package/anims/chrome/black-hole.js +81 -0
- package/anims/chrome/cloth-reveal.js +156 -0
- package/anims/chrome/dna-helix.js +52 -0
- package/anims/chrome/dust-bust.js +109 -0
- package/anims/chrome/extruded-type.js +64 -0
- package/anims/chrome/fractal-tree.js +75 -0
- package/anims/chrome/gem.js +111 -0
- package/anims/chrome/geode.js +102 -0
- package/anims/chrome/glass-lens.js +73 -0
- package/anims/chrome/glass-prism.js +95 -0
- package/anims/chrome/gyroid.js +69 -0
- package/anims/chrome/holo-card.js +52 -0
- package/anims/chrome/infinite-tunnel.js +81 -0
- package/anims/chrome/inflated-type.js +141 -0
- package/anims/chrome/isometric-city.js +97 -0
- package/anims/chrome/jelly-cube.js +329 -0
- package/anims/chrome/klein-bottle.js +80 -0
- package/anims/chrome/liquid-metal.js +94 -0
- package/anims/chrome/low-poly-planet.js +163 -0
- package/anims/chrome/mandelbulb.js +79 -0
- package/anims/chrome/melting-logo.js +126 -0
- package/anims/chrome/menger-sponge.js +79 -0
- package/anims/chrome/neon-geodesic.js +149 -0
- package/anims/chrome/paper-fold.js +135 -0
- package/anims/chrome/path-spheres.js +112 -0
- package/anims/chrome/point-globe.js +81 -0
- package/anims/chrome/portal.js +105 -0
- package/anims/chrome/rubiks-cube.js +74 -0
- package/anims/chrome/sdf-morph.js +129 -0
- package/anims/chrome/shatter.js +120 -0
- package/anims/chrome/soap-bubble.js +86 -0
- package/anims/chrome/terrain-flyover.js +78 -0
- package/anims/chrome/tesseract.js +148 -0
- package/anims/chrome/torus-knot.js +89 -0
- package/anims/chrome/volumetric-clouds.js +70 -0
- package/anims/flow/bio-shore.js +140 -0
- package/anims/flow/calligraphy.js +116 -0
- package/anims/flow/chladni.js +152 -0
- package/anims/flow/cloud-chamber.js +101 -0
- package/anims/flow/confetti.js +46 -0
- package/anims/flow/curl-flow.js +52 -0
- package/anims/flow/dandelion.js +80 -0
- package/anims/flow/dla-crystal.js +57 -0
- package/anims/flow/drone-show.js +357 -0
- package/anims/flow/embers.js +63 -0
- package/anims/flow/firefly-sync.js +107 -0
- package/anims/flow/galaxy.js +69 -0
- package/anims/flow/galton-board.js +155 -0
- package/anims/flow/gpu-particles.js +73 -0
- package/anims/flow/harmonograph.js +61 -0
- package/anims/flow/holi.js +153 -0
- package/anims/flow/iron-filings.js +112 -0
- package/anims/flow/lightning.js +72 -0
- package/anims/flow/murmuration.js +105 -0
- package/anims/flow/orbitals.js +195 -0
- package/anims/flow/particle-life.js +87 -0
- package/anims/flow/physarum.js +171 -0
- package/anims/flow/pin-art.js +181 -0
- package/anims/flow/rainbow.js +127 -0
- package/anims/flow/roche-limit.js +161 -0
- package/anims/flow/sand-mandala.js +121 -0
- package/anims/flow/scramble-crossing.js +152 -0
- package/anims/flow/snowfall.js +84 -0
- package/anims/flow/sparks.js +71 -0
- package/anims/flow/spider-web.js +123 -0
- package/anims/flow/starry-night.js +121 -0
- package/anims/flow/states-of-matter.js +113 -0
- package/anims/flow/strange-attractor.js +54 -0
- package/anims/flow/wheat-field.js +186 -0
- package/anims/flow/wind-tunnel.js +227 -0
- package/anims/fluid/bubbles.js +152 -0
- package/anims/fluid/caustics.js +45 -0
- package/anims/fluid/dripping-text.js +53 -0
- package/anims/fluid/ferrofluid.js +56 -0
- package/anims/fluid/fire-smoke.js +83 -0
- package/anims/fluid/honey-drip.js +167 -0
- package/anims/fluid/ink-drop.js +50 -0
- package/anims/fluid/ink-fluid.js +125 -0
- package/anims/fluid/lava-lamp.js +56 -0
- package/anims/fluid/liquid-distort.js +64 -0
- package/anims/fluid/liquid-glass-fill.js +53 -0
- package/anims/fluid/marbling.js +53 -0
- package/anims/fluid/mercury.js +177 -0
- package/anims/fluid/ocean-waves.js +50 -0
- package/anims/fluid/paint-swirl.js +153 -0
- package/anims/fluid/raindrops-glass.js +60 -0
- package/anims/fluid/ripples.js +57 -0
- package/anims/fluid/sph-water.js +75 -0
- package/anims/fluid/underwater.js +55 -0
- package/anims/fluid/water-surface-3d.js +98 -0
- package/anims/hover/cursor-trail.js +37 -0
- package/anims/hover/magnetic.js +41 -0
- package/anims/hover/ripple.js +31 -0
- package/anims/hover/spotlight.js +31 -0
- package/anims/hover/tilt.js +36 -0
- package/anims/micro/arc-spinner.js +22 -0
- package/anims/micro/like-burst.js +42 -0
- package/anims/micro/skeleton.js +36 -0
- package/anims/micro/submit-success.js +45 -0
- package/anims/micro/toggle.js +35 -0
- package/anims/morph/gooey.js +36 -0
- package/anims/morph/icon-morph.js +45 -0
- package/anims/morph/line-draw.js +34 -0
- package/anims/morph/liquid-button.js +53 -0
- package/anims/morph/shape-morph.js +61 -0
- package/anims/registry-more.js +327 -0
- package/anims/registry.js +294 -0
- package/anims/scroll/parallax.js +50 -0
- package/anims/scroll/pinned-track.js +40 -0
- package/anims/scroll/scrub-reveal.js +34 -0
- package/anims/scroll/velocity-skew.js +38 -0
- package/anims/scroll/zoom-through.js +41 -0
- package/anims/shaders/aurora.js +47 -0
- package/anims/shaders/dither.js +69 -0
- package/anims/shaders/domain-warp.js +52 -0
- package/anims/shaders/guilloche.js +42 -0
- package/anims/shaders/kaleidoscope.js +64 -0
- package/anims/shaders/reaction-diffusion.js +85 -0
- package/anims/shaders/rgb-glitch.js +56 -0
- package/anims/shaders/truchet.js +57 -0
- package/anims/shaders/voronoi.js +50 -0
- package/anims/springs/ball-collisions.js +47 -0
- package/anims/springs/pendulum-wave.js +35 -0
- package/anims/springs/rubber-band.js +50 -0
- package/anims/springs/squash-bounce.js +50 -0
- package/anims/springs/verlet-rope.js +46 -0
- package/anims/text/letter-rise.js +40 -0
- package/anims/text/line-mask.js +26 -0
- package/anims/text/scramble.js +32 -0
- package/anims/text/typewriter.js +35 -0
- package/anims/text/vf-wave.js +26 -0
- package/anims/transitions/circle-reveal.js +42 -0
- package/anims/transitions/push-slide.js +40 -0
- package/anims/transitions/shared-expand.js +43 -0
- package/anims/transitions/staircase-wipe.js +41 -0
- package/anims/transitions/view-transition.js +43 -0
- package/bin.mjs +155 -0
- package/js/code.js +122 -0
- package/package.json +10 -4
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Spotlight reveal: a dark layer has a soft hole cut by a CSS mask that follows the pointer.
|
|
2
|
+
const PARAMS = { radius: 110 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const words = ['FRAME', 'SPRING', 'EASE', 'CURVE', 'TIMING', 'WEIGHT', 'ARC', 'SNAP', 'DRIFT', 'POP', 'BLUR', 'LOOP'];
|
|
6
|
+
const root = document.createElement('div');
|
|
7
|
+
root.className = 'sl-root';
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.sl-root { position: absolute; inset: 0; overflow: hidden; font-family: "Mona Sans", system-ui, sans-serif; cursor: none; }
|
|
10
|
+
.sl-under { position: absolute; inset: -4%; background: #fab005; display: flex; flex-wrap: wrap; align-content: center; gap: 0 3vmin; padding: 2vmin; font-weight: 900; font-size: 11vmin; line-height: 1.02; color: #15171a; letter-spacing: -0.03em; }
|
|
11
|
+
.sl-under span:nth-child(3n) { color: #e8590c; } .sl-under span:nth-child(4n) { color: #fff; }
|
|
12
|
+
.sl-over { position: absolute; inset: 0; background: #15171a; }
|
|
13
|
+
.sl-hint { position: absolute; left: 6%; bottom: 7%; color: #868e96; font-size: max(11px, 3.6vmin); font-weight: 600; }
|
|
14
|
+
</style><div class="sl-under">${words.map(w => `<span>${w}</span>`).join('')}</div><div class="sl-over"></div><div class="sl-hint">Move to look around</div>`;
|
|
15
|
+
stage.append(root);
|
|
16
|
+
const over = root.querySelector('.sl-over');
|
|
17
|
+
let px = 0, py = 0, lastMove = -1e9, sx = root.clientWidth / 2, sy = root.clientHeight / 2, raf;
|
|
18
|
+
const onMove = e => { const b = root.getBoundingClientRect(); px = e.clientX - b.left; py = e.clientY - b.top; lastMove = performance.now(); };
|
|
19
|
+
root.addEventListener('pointermove', onMove);
|
|
20
|
+
function frame(now) {
|
|
21
|
+
let x = px, y = py;
|
|
22
|
+
if (now - lastMove > 1500) { const t = now / 1000, w = root.clientWidth, h = root.clientHeight; x = w / 2 + Math.cos(t * 0.9) * w * 0.32; y = h / 2 + Math.sin(t * 1.4) * h * 0.26; }
|
|
23
|
+
sx += (x - sx) * 0.14; sy += (y - sy) * 0.14; // the light trails the pointer slightly
|
|
24
|
+
const r = PARAMS.radius;
|
|
25
|
+
const mask = `radial-gradient(circle ${r}px at ${sx}px ${sy}px, transparent 0, transparent ${r * 0.55}px, #000 ${r}px)`;
|
|
26
|
+
over.style.maskImage = mask; over.style.webkitMaskImage = mask;
|
|
27
|
+
raf = requestAnimationFrame(frame);
|
|
28
|
+
}
|
|
29
|
+
raf = requestAnimationFrame(frame);
|
|
30
|
+
return () => { cancelAnimationFrame(raf); root.removeEventListener('pointermove', onMove); root.remove(); };
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 3D tilt card: pointer position becomes rotateX/rotateY through a spring; a glare moves opposite the tilt.
|
|
2
|
+
const PARAMS = { maxTilt: 14, stiffness: 120 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'tl-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.tl-root { position: absolute; inset: 0; display: grid; place-items: center; background: #f3ead2; perspective: 700px; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.tl-card { position: relative; width: min(58%, 260px); aspect-ratio: 3 / 4; border-radius: 16px; overflow: hidden; transform-style: preserve-3d; will-change: transform; background: #ff922b; }
|
|
10
|
+
.tl-sky { position: absolute; inset: 0 0 45% 0; background: #ffd8a8; }
|
|
11
|
+
.tl-sun { position: absolute; width: 46%; aspect-ratio: 1; border-radius: 50%; background: #f76707; left: 27%; top: 30%; }
|
|
12
|
+
.tl-sea { position: absolute; inset: 55% 0 0 0; background: #1c3d5a; }
|
|
13
|
+
.tl-sea i { position: absolute; left: 12%; right: 12%; height: 3%; background: #f76707; border-radius: 4px; }
|
|
14
|
+
.tl-t { position: absolute; left: 8%; bottom: 7%; color: #fff; font-weight: 750; font-size: clamp(12px, 5vmin, 22px); }
|
|
15
|
+
.tl-glare { position: absolute; inset: -50%; background: radial-gradient(circle at center, rgba(255,255,255,.55), rgba(255,255,255,0) 40%); pointer-events: none; mix-blend-mode: soft-light; }
|
|
16
|
+
</style><div class="tl-card"><div class="tl-sky"></div><div class="tl-sun"></div><div class="tl-sea"><i style="top:18%"></i><i style="top:40%;left:22%;right:22%"></i><i style="top:62%;left:32%;right:32%"></i></div><div class="tl-t">Golden hour</div><div class="tl-glare"></div></div>`;
|
|
17
|
+
stage.append(root);
|
|
18
|
+
const card = root.querySelector('.tl-card'), glare = root.querySelector('.tl-glare');
|
|
19
|
+
let nx = 0, ny = 0, lastMove = -1e9;
|
|
20
|
+
const onMove = e => { const b = card.getBoundingClientRect(); nx = ((e.clientX - b.left) / b.width) * 2 - 1; ny = ((e.clientY - b.top) / b.height) * 2 - 1; lastMove = performance.now(); };
|
|
21
|
+
root.addEventListener('pointermove', onMove);
|
|
22
|
+
const rx = { x: 0, v: 0 }, ry = { x: 0, v: 0 };
|
|
23
|
+
const spring = (s, t, dt) => { const k = PARAMS.stiffness, c = 2 * Math.sqrt(k) * 0.7; s.v += (-k * (s.x - t) - c * s.v) * dt; s.x += s.v * dt; };
|
|
24
|
+
let last = performance.now(), raf;
|
|
25
|
+
function frame(now) {
|
|
26
|
+
const dt = Math.min(0.032, (now - last) / 1000); last = now;
|
|
27
|
+
let x = Math.max(-1, Math.min(1, nx)), y = Math.max(-1, Math.min(1, ny));
|
|
28
|
+
if (now - lastMove > 1500) { const t = now / 1000; x = Math.sin(t * 1.2) * 0.9; y = Math.cos(t * 0.9) * 0.7; }
|
|
29
|
+
spring(rx, -y * PARAMS.maxTilt, dt); spring(ry, x * PARAMS.maxTilt, dt);
|
|
30
|
+
card.style.transform = `rotateX(${rx.x}deg) rotateY(${ry.x}deg)`;
|
|
31
|
+
glare.style.transform = `translate(${-ry.x * 2.2}%, ${rx.x * 2.2}%)`;
|
|
32
|
+
raf = requestAnimationFrame(frame);
|
|
33
|
+
}
|
|
34
|
+
raf = requestAnimationFrame(frame);
|
|
35
|
+
return () => { cancelAnimationFrame(raf); root.removeEventListener('pointermove', onMove); root.remove(); };
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Arc spinner: the ring rotates while its dash grows and shrinks on a different period, so it never quite repeats.
|
|
2
|
+
const PARAMS = { speed: 1 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'as-root';
|
|
7
|
+
const rot = (2 / PARAMS.speed).toFixed(2), dash = (1.5 / PARAMS.speed).toFixed(2);
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.as-root { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 9%; background: #1a0f14; container-type: size; }
|
|
10
|
+
.as-root svg { width: 26cqmin; height: 26cqmin; animation: as-rot ${rot}s linear infinite; }
|
|
11
|
+
.as-root svg:nth-child(2) { width: 16cqmin; height: 16cqmin; animation-duration: ${(rot * 0.8).toFixed(2)}s; }
|
|
12
|
+
.as-root svg:nth-child(3) { width: 10cqmin; height: 10cqmin; animation-duration: ${(rot * 0.6).toFixed(2)}s; }
|
|
13
|
+
.as-root circle { fill: none; stroke-width: 5; stroke-linecap: round; stroke-dasharray: 1 200; stroke-dashoffset: 0; animation: as-dash ${dash}s ease-in-out infinite; }
|
|
14
|
+
@keyframes as-rot { to { transform: rotate(360deg); } }
|
|
15
|
+
@keyframes as-dash { 0% { stroke-dasharray: 1 200; stroke-dashoffset: 0; } 50% { stroke-dasharray: 90 200; stroke-dashoffset: -35; } 100% { stroke-dasharray: 90 200; stroke-dashoffset: -124; } }
|
|
16
|
+
</style>
|
|
17
|
+
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" stroke="#ff4d8d"/></svg>
|
|
18
|
+
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" stroke="#ffd43b"/></svg>
|
|
19
|
+
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" stroke="#f1f3f5"/></svg>`;
|
|
20
|
+
stage.append(root);
|
|
21
|
+
return () => root.remove();
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Like burst: the heart squashes, pops past full size and settles while a ring and sparks fire on the same frame.
|
|
2
|
+
const PARAMS = { particles: 10, spread: 1 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'lb-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.lb-root { position: absolute; inset: 0; display: grid; place-items: center; background: #2a0f1c; container-type: size; }
|
|
9
|
+
.lb-btn { position: relative; width: 30cqmin; height: 30cqmin; border: 0; background: none; padding: 0; cursor: pointer; }
|
|
10
|
+
.lb-btn svg { position: relative; width: 100%; height: 100%; z-index: 1; }
|
|
11
|
+
.lb-btn path { fill: #4a2236; transition: fill .15s; }
|
|
12
|
+
.lb-btn.on path { fill: #ff3b6b; }
|
|
13
|
+
.lb-ring { position: absolute; inset: -20%; border-radius: 50%; border: 2cqmin solid #ff8fab; opacity: 0; }
|
|
14
|
+
.lb-spark { position: absolute; left: 50%; top: 50%; width: 3.4cqmin; height: 3.4cqmin; margin: -1.7cqmin; border-radius: 50%; opacity: 0; }
|
|
15
|
+
</style><button class="lb-btn" type="button" aria-label="Like" aria-pressed="false"><span class="lb-ring"></span><svg viewBox="0 0 24 24"><path d="M12 21s-7.5-4.6-9.6-9.2C.9 8.4 3 4.5 6.7 4.5c2.2 0 3.6 1.2 5.3 3.2 1.7-2 3.1-3.2 5.3-3.2 3.7 0 5.8 3.9 4.3 7.3C19.5 16.4 12 21 12 21z"/></svg></button>`;
|
|
16
|
+
stage.append(root);
|
|
17
|
+
const btn = root.querySelector('.lb-btn'), svg = btn.querySelector('svg'), ring = root.querySelector('.lb-ring');
|
|
18
|
+
const colours = ['#ff3b6b', '#ffd43b', '#ff8fab', '#74c0fc', '#ffffff'];
|
|
19
|
+
|
|
20
|
+
function burst() {
|
|
21
|
+
const on = !btn.classList.contains('on');
|
|
22
|
+
btn.classList.toggle('on', on); btn.setAttribute('aria-pressed', String(on));
|
|
23
|
+
if (!on) return;
|
|
24
|
+
svg.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.78, 0.72)', offset: 0.18 }, { transform: 'scale(1.22)', offset: 0.5 }, { transform: 'scale(0.95)', offset: 0.75 }, { transform: 'scale(1)' }],
|
|
25
|
+
{ duration: 620, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' });
|
|
26
|
+
ring.animate([{ transform: 'scale(0.3)', opacity: 0.9, borderWidth: '3cqmin' }, { transform: 'scale(1.15)', opacity: 0, borderWidth: '0cqmin' }], { duration: 520, delay: 90, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
|
|
27
|
+
const n = Math.round(PARAMS.particles), size = btn.clientWidth;
|
|
28
|
+
for (let i = 0; i < n; i++) {
|
|
29
|
+
const s = document.createElement('i'); s.className = 'lb-spark'; s.style.background = colours[i % colours.length]; btn.append(s);
|
|
30
|
+
const a = (i / n) * Math.PI * 2 + Math.random() * 0.4, d = size * (0.7 + Math.random() * 0.35) * PARAMS.spread;
|
|
31
|
+
s.animate([{ transform: 'translate(0,0) scale(1)', opacity: 1 }, { transform: `translate(${Math.cos(a) * d}px, ${Math.sin(a) * d}px) scale(0.2)`, opacity: 0 }],
|
|
32
|
+
{ duration: 600 + Math.random() * 200, delay: 90, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }).onfinish = () => s.remove();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let manual = -1e9;
|
|
36
|
+
const onClick = () => { burst(); manual = performance.now(); };
|
|
37
|
+
btn.addEventListener('click', onClick);
|
|
38
|
+
// demo loop: reset quietly, then like again, so every beat shows the burst
|
|
39
|
+
const auto = setInterval(() => { if (performance.now() - manual < 3000) return; btn.classList.remove('on'); burst(); }, 1200);
|
|
40
|
+
const first = setTimeout(burst, 350);
|
|
41
|
+
return () => { clearTimeout(first); clearInterval(auto); btn.removeEventListener('click', onClick); root.remove(); };
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Skeleton shimmer: placeholders shaped like the real layout, a light sweep while loading, then a cross-fade to content.
|
|
2
|
+
const PARAMS = { speed: 1.4 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'sk-root';
|
|
7
|
+
const dur = (1.6 / PARAMS.speed).toFixed(2);
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.sk-root { position: absolute; inset: 0; display: grid; place-items: center; background: #f1f3f5; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
10
|
+
.sk-card { position: relative; width: 76cqw; max-width: 340px; background: #fff; border-radius: 14px; padding: 14px; display: grid; gap: 10px; }
|
|
11
|
+
.sk-layer { display: grid; grid-template-columns: 40px 1fr; gap: 10px 12px; align-items: center; }
|
|
12
|
+
.sk-real { position: absolute; inset: 14px; opacity: 0; }
|
|
13
|
+
.sk-b { border-radius: 6px; background: linear-gradient(90deg, #eceef1 0%, #eceef1 40%, #f8f9fa 50%, #eceef1 60%, #eceef1 100%); background-size: 300% 100%; animation: sk-sh ${dur}s linear infinite; }
|
|
14
|
+
@keyframes sk-sh { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
|
15
|
+
.sk-av { width: 40px; height: 40px; border-radius: 50%; }
|
|
16
|
+
.sk-img { grid-column: 1 / -1; height: 26cqh; border-radius: 10px; }
|
|
17
|
+
.sk-av.r { background: #d63c74; color: #fff; display: grid; place-items: center; font-weight: 700; font-size: 15px; }
|
|
18
|
+
.sk-name { font-weight: 700; font-size: 14px; color: #15171a; } .sk-meta { font-size: 12.5px; color: #5d6166; }
|
|
19
|
+
.sk-img.r { background: #ffdeeb; display: grid; place-items: center; color: #a61e4d; font-weight: 750; font-size: 6cqmin; }
|
|
20
|
+
</style>
|
|
21
|
+
<div class="sk-card">
|
|
22
|
+
<div class="sk-layer sk-skel"><div class="sk-b sk-av"></div><div style="display:grid;gap:7px"><div class="sk-b" style="height:12px;width:60%"></div><div class="sk-b" style="height:10px;width:40%"></div></div><div class="sk-b sk-img"></div></div>
|
|
23
|
+
<div class="sk-layer sk-real"><div class="sk-av r">MJ</div><div><div class="sk-name">Maya Jensen</div><div class="sk-meta">Posted a new loop · 2 min ago</div></div><div class="sk-img r">Bounce v3</div></div>
|
|
24
|
+
</div>`;
|
|
25
|
+
stage.append(root);
|
|
26
|
+
const skel = root.querySelector('.sk-skel'), real = root.querySelector('.sk-real');
|
|
27
|
+
let loaded = false;
|
|
28
|
+
const swap = () => {
|
|
29
|
+
loaded = !loaded;
|
|
30
|
+
const o = { duration: 350, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' };
|
|
31
|
+
real.animate([{ opacity: loaded ? 0 : 1 }, { opacity: loaded ? 1 : 0 }], o);
|
|
32
|
+
skel.animate([{ opacity: loaded ? 1 : 0 }, { opacity: loaded ? 0 : 1 }], o);
|
|
33
|
+
};
|
|
34
|
+
let t1 = setTimeout(function tick() { swap(); t1 = setTimeout(tick, loaded ? 1800 : 2200); }, 2200);
|
|
35
|
+
return () => { clearTimeout(t1); root.remove(); };
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Submit to success: the button morphs into a spinner, then draws a tick, so one control shows every state.
|
|
2
|
+
const PARAMS = { duration: 400 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'ss-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.ss-root { position: absolute; inset: 0; display: grid; place-items: center; background: #fff0f6; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.ss-btn { position: relative; width: 180px; height: 56px; border: 0; padding: 0; background: #d63c74; color: #fff; font: 700 17px "Mona Sans", system-ui, sans-serif; cursor: pointer; clip-path: inset(0 0 round 28px); }
|
|
10
|
+
.ss-btn > * { position: absolute; inset: 0; display: grid; place-items: center; }
|
|
11
|
+
.ss-spin, .ss-tick { opacity: 0; }
|
|
12
|
+
.ss-spin svg { width: 30px; height: 30px; animation: ss-rot .8s linear infinite; }
|
|
13
|
+
@keyframes ss-rot { to { transform: rotate(360deg); } }
|
|
14
|
+
.ss-tick svg { width: 28px; height: 28px; }
|
|
15
|
+
</style><button class="ss-btn" type="button"><span class="ss-label">Send message</span><span class="ss-spin"><svg viewBox="0 0 30 30"><circle cx="15" cy="15" r="12" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="48 100"/></svg></span><span class="ss-tick"><svg viewBox="0 0 28 28"><path d="M6 14.5l5.2 5.2L22 8.8" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span></button>`;
|
|
16
|
+
stage.append(root);
|
|
17
|
+
const btn = root.querySelector('.ss-btn'), label = root.querySelector('.ss-label'), spin = root.querySelector('.ss-spin'), tick = root.querySelector('.ss-tick'), path = tick.querySelector('path');
|
|
18
|
+
const len = path.getTotalLength(); path.style.strokeDasharray = len; path.style.strokeDashoffset = len;
|
|
19
|
+
const ease = 'cubic-bezier(0.23, 1, 0.32, 1)', D = PARAMS.duration, side = (180 - 56) / 2;
|
|
20
|
+
const opts = (extra = {}) => ({ duration: D, easing: ease, fill: 'forwards', ...extra });
|
|
21
|
+
let timers = [], running = false;
|
|
22
|
+
const later = (fn, ms) => timers.push(setTimeout(fn, ms));
|
|
23
|
+
function run() {
|
|
24
|
+
if (running) return; running = true;
|
|
25
|
+
label.animate([{ opacity: 1 }, { opacity: 0 }], opts({ duration: D * 0.4 }));
|
|
26
|
+
btn.animate([{ clipPath: 'inset(0 0 round 28px)' }, { clipPath: `inset(0 ${side}px round 28px)` }], opts());
|
|
27
|
+
spin.animate([{ opacity: 0 }, { opacity: 1 }], opts({ delay: D * 0.6, duration: 200 }));
|
|
28
|
+
later(() => {
|
|
29
|
+
spin.animate([{ opacity: 1 }, { opacity: 0 }], opts({ duration: 150 }));
|
|
30
|
+
btn.animate([{ background: '#d63c74' }, { background: '#2f9e44' }], opts({ duration: 250 }));
|
|
31
|
+
tick.animate([{ opacity: 0 }, { opacity: 1 }], opts({ duration: 100 }));
|
|
32
|
+
path.animate([{ strokeDashoffset: len }, { strokeDashoffset: 0 }], opts({ duration: 420, delay: 80 }));
|
|
33
|
+
}, D + 1300);
|
|
34
|
+
later(() => {
|
|
35
|
+
tick.animate([{ opacity: 1 }, { opacity: 0 }], opts({ duration: 200 }));
|
|
36
|
+
btn.animate([{ clipPath: `inset(0 ${side}px round 28px)`, background: '#2f9e44' }, { clipPath: 'inset(0 0 round 28px)', background: '#d63c74' }], opts());
|
|
37
|
+
label.animate([{ opacity: 0 }, { opacity: 1 }], opts({ delay: D * 0.5, duration: 200 }));
|
|
38
|
+
}, D + 2900);
|
|
39
|
+
later(() => { running = false; }, D * 2 + 3000);
|
|
40
|
+
}
|
|
41
|
+
btn.addEventListener('click', run);
|
|
42
|
+
run();
|
|
43
|
+
const loop = setInterval(run, D * 2 + 4000);
|
|
44
|
+
return () => { timers.forEach(clearTimeout); clearInterval(loop); btn.removeEventListener('click', run); root.remove(); };
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Toggle spring: the knob rides a spring and stretches with its speed; the track colour follows the knob.
|
|
2
|
+
const PARAMS = { stiffness: 300, damping: 20 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'tg-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.tg-root { position: absolute; inset: 0; display: grid; place-items: center; background: #fff0f6; container-type: size; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.tg-sw { position: relative; width: 44cqmin; height: 24cqmin; border-radius: 12cqmin; border: 0; padding: 0; cursor: pointer; background: #d0d4da; }
|
|
10
|
+
.tg-knob { position: absolute; top: 2.4cqmin; left: 2.4cqmin; height: 19.2cqmin; width: 19.2cqmin; border-radius: 9.6cqmin; background: #fff; box-shadow: 0 1cqmin 2cqmin rgba(80, 10, 40, .18); will-change: transform, width; }
|
|
11
|
+
.tg-label { position: absolute; bottom: 14%; font-size: max(11px, 4cqmin); font-weight: 600; color: #a61e4d; }
|
|
12
|
+
</style><button class="tg-sw" type="button" role="switch" aria-checked="false" aria-label="Notifications"><span class="tg-knob"></span></button><div class="tg-label">Notifications off</div>`;
|
|
13
|
+
stage.append(root);
|
|
14
|
+
const sw = root.querySelector('.tg-sw'), knob = root.querySelector('.tg-knob'), label = root.querySelector('.tg-label');
|
|
15
|
+
let on = false, x = 0, v = 0, manual = -1e9, last = performance.now(), raf;
|
|
16
|
+
const set = val => { on = val; sw.setAttribute('aria-checked', String(on)); label.textContent = on ? 'Notifications on' : 'Notifications off'; };
|
|
17
|
+
const onClick = () => { set(!on); manual = performance.now(); };
|
|
18
|
+
sw.addEventListener('click', onClick);
|
|
19
|
+
const auto = setInterval(() => { if (performance.now() - manual > 3000) set(!on); }, 1300);
|
|
20
|
+
const mix = (a, b, t) => `rgb(${a.map((c, i) => Math.round(c + (b[i] - c) * t)).join(',')})`;
|
|
21
|
+
function frame(now) {
|
|
22
|
+
const dt = Math.min(0.032, (now - last) / 1000); last = now;
|
|
23
|
+
for (let i = 0; i < 4; i++) { const h = dt / 4; v += (-PARAMS.stiffness * (x - (on ? 1 : 0)) - PARAMS.damping * v) * h; x += v * h; }
|
|
24
|
+
const travel = sw.clientWidth - knob.offsetHeight - knob.offsetTop * 2;
|
|
25
|
+
const stretch = Math.min(0.55, Math.abs(v) * 0.06); // wider while moving fast
|
|
26
|
+
const w = knob.offsetHeight * (1 + stretch);
|
|
27
|
+
knob.style.width = w + 'px';
|
|
28
|
+
// stretch toward the direction of travel: moving left, grow from the right edge
|
|
29
|
+
knob.style.transform = `translateX(${x * travel - (v < 0 ? w - knob.offsetHeight : 0)}px)`;
|
|
30
|
+
sw.style.background = mix([208, 212, 218], [214, 60, 116], Math.max(0, Math.min(1, x)));
|
|
31
|
+
raf = requestAnimationFrame(frame);
|
|
32
|
+
}
|
|
33
|
+
raf = requestAnimationFrame(frame);
|
|
34
|
+
return () => { cancelAnimationFrame(raf); clearInterval(auto); sw.removeEventListener('click', onClick); root.remove(); };
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Gooey blobs: blur everything together, then an SVG colour matrix turns soft alpha into a hard edge.
|
|
2
|
+
const PARAMS = { blur: 14, blobs: 6 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const id = 'goo-' + Math.random().toString(36).slice(2, 8);
|
|
6
|
+
const root = document.createElement('div');
|
|
7
|
+
root.className = 'go-root';
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.go-root { position: absolute; inset: 0; background: #fbeefa; container-type: size; }
|
|
10
|
+
.go-field { position: absolute; inset: 0; filter: url(#${id}); }
|
|
11
|
+
.go-blob { position: absolute; left: 0; top: 0; border-radius: 50%; background: #b8359a; will-change: transform; }
|
|
12
|
+
</style>
|
|
13
|
+
<svg width="0" height="0" style="position:absolute"><filter id="${id}"><feGaussianBlur in="SourceGraphic" stdDeviation="${PARAMS.blur}"/><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9"/></filter></svg>
|
|
14
|
+
<div class="go-field"></div>`;
|
|
15
|
+
stage.append(root);
|
|
16
|
+
const field = root.querySelector('.go-field');
|
|
17
|
+
const s = Math.min(stage.clientWidth, stage.clientHeight);
|
|
18
|
+
const blobs = Array.from({ length: Math.round(PARAMS.blobs) }, (_, i) => {
|
|
19
|
+
const el = document.createElement('div'); el.className = 'go-blob';
|
|
20
|
+
const r = s * (0.13 + (i % 3) * 0.04); el.style.width = el.style.height = r * 2 + 'px';
|
|
21
|
+
if (i % 3 === 1) el.style.background = '#f06595';
|
|
22
|
+
field.append(el);
|
|
23
|
+
return { el, r, fx: 0.4 + Math.random() * 0.7, fy: 0.5 + Math.random() * 0.8, ph: Math.random() * 6.28 };
|
|
24
|
+
});
|
|
25
|
+
let raf;
|
|
26
|
+
function frame(now) {
|
|
27
|
+
const t = now / 1000, W = root.clientWidth, H = root.clientHeight;
|
|
28
|
+
for (const b of blobs) {
|
|
29
|
+
const x = W / 2 + Math.sin(t * b.fx + b.ph) * W * 0.26 - b.r, y = H / 2 + Math.cos(t * b.fy + b.ph * 1.3) * H * 0.24 - b.r;
|
|
30
|
+
b.el.style.transform = `translate(${x}px, ${y}px)`;
|
|
31
|
+
}
|
|
32
|
+
raf = requestAnimationFrame(frame);
|
|
33
|
+
}
|
|
34
|
+
raf = requestAnimationFrame(frame);
|
|
35
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Icon morph: each icon pair is drawn with the same points in the same order, then blended point by point.
|
|
2
|
+
const PARAMS = { duration: 380 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'im-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.im-root { position: absolute; inset: 0; display: grid; grid-auto-flow: column; place-content: center; gap: 12cqmin; background: #2a0f26; container-type: size; }
|
|
9
|
+
.im-btn { width: 30cqmin; height: 30cqmin; border-radius: 50%; border: 0; background: #b8359a; display: grid; place-items: center; cursor: pointer; padding: 0; }
|
|
10
|
+
.im-btn:nth-child(2) { background: #fbeefa; }
|
|
11
|
+
.im-btn svg { width: 55%; height: 55%; overflow: visible; }
|
|
12
|
+
</style>
|
|
13
|
+
<button class="im-btn" type="button" aria-label="Play"><svg viewBox="0 0 24 24"><path fill="#fff"/></svg></button>
|
|
14
|
+
<button class="im-btn" type="button" aria-label="Menu"><svg viewBox="0 0 24 24"><path fill="none" stroke="#2a0f26" stroke-width="2.6" stroke-linecap="round"/></svg></button>`;
|
|
15
|
+
stage.append(root);
|
|
16
|
+
const [b1, b2] = root.querySelectorAll('.im-btn');
|
|
17
|
+
const p1 = b1.querySelector('path'), p2 = b2.querySelector('path'), s1 = b1.querySelector('svg'), s2 = b2.querySelector('svg');
|
|
18
|
+
|
|
19
|
+
// Play is a triangle cut into two four-point halves, pause is two bars: same point count, same order.
|
|
20
|
+
const pause = [[[5, 4], [10, 4], [10, 20], [5, 20]], [[14, 4], [19, 4], [19, 20], [14, 20]]];
|
|
21
|
+
const play = [[[6, 3], [13, 7.5], [13, 16.5], [6, 21]], [[13, 7.5], [20, 12], [20, 12], [13, 16.5]]];
|
|
22
|
+
// Menu is three lines; close is an X with the middle line collapsed to its centre.
|
|
23
|
+
const menu = [[[4, 6], [20, 6]], [[4, 12], [20, 12]], [[4, 18], [20, 18]]];
|
|
24
|
+
const close = [[[5.5, 5.5], [18.5, 18.5]], [[12, 12], [12, 12]], [[5.5, 18.5], [18.5, 5.5]]];
|
|
25
|
+
const lerpShape = (a, b, t) => a.map((poly, i) => poly.map((pt, j) => [pt[0] + (b[i][j][0] - pt[0]) * t, pt[1] + (b[i][j][1] - pt[1]) * t]));
|
|
26
|
+
const toD = (shape, closed) => shape.map(poly => 'M' + poly.map(p => p.map(n => n.toFixed(2)).join(' ')).join('L') + (closed ? 'Z' : '')).join('');
|
|
27
|
+
const ease = t => 1 - Math.pow(1 - t, 3);
|
|
28
|
+
|
|
29
|
+
let from = 0, to = 0, start = 0, raf;
|
|
30
|
+
const toggle = () => { from = to; to = to ? 0 : 1; start = performance.now(); b1.setAttribute('aria-label', to ? 'Pause' : 'Play'); b2.setAttribute('aria-label', to ? 'Close' : 'Menu'); };
|
|
31
|
+
let manual = -1e9;
|
|
32
|
+
const onClick = () => { toggle(); manual = performance.now(); };
|
|
33
|
+
b1.addEventListener('click', onClick); b2.addEventListener('click', onClick);
|
|
34
|
+
const auto = setInterval(() => { if (performance.now() - manual > 3000) toggle(); }, PARAMS.duration + 900);
|
|
35
|
+
function frame(now) {
|
|
36
|
+
const k = ease(Math.min(1, (now - start) / PARAMS.duration)), v = from + (to - from) * k;
|
|
37
|
+
p1.setAttribute('d', toD(lerpShape(play, pause, v), true));
|
|
38
|
+
p2.setAttribute('d', toD(lerpShape(menu, close, v), false));
|
|
39
|
+
const spin = Math.sin(Math.PI * k) * (to > from ? 1 : -1) * 18; // a little turn hides the in-between shapes
|
|
40
|
+
s1.style.transform = `rotate(${spin}deg)`; s2.style.transform = `rotate(${spin * 2}deg)`;
|
|
41
|
+
raf = requestAnimationFrame(frame);
|
|
42
|
+
}
|
|
43
|
+
raf = requestAnimationFrame(frame);
|
|
44
|
+
return () => { cancelAnimationFrame(raf); clearInterval(auto); root.remove(); };
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Line draw-on: dash length = path length; animating the dash offset from that length to 0 draws the line.
|
|
2
|
+
const PARAMS = { duration: 2200 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'ld-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.ld-root { position: absolute; inset: 0; display: grid; place-items: center; background: #fff9db; }
|
|
9
|
+
.ld-root svg { width: 86%; height: 86%; }
|
|
10
|
+
.ld-root path, .ld-root circle { fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
|
11
|
+
</style>
|
|
12
|
+
<svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet">
|
|
13
|
+
<circle cx="300" cy="78" r="34" stroke="#f08c00" stroke-width="5"/>
|
|
14
|
+
<path d="M20 220 L110 110 L160 170 L215 90 L300 200 L380 220" stroke="#b8359a" stroke-width="6"/>
|
|
15
|
+
<path d="M20 250 Q 50 232 80 250 T 140 250 T 200 250 T 260 250 T 320 250 T 380 250" stroke="#1c7ed6" stroke-width="5"/>
|
|
16
|
+
<path d="M40 278 Q 70 262 100 278 T 160 278 T 220 278 T 280 278 T 340 278" stroke="#74c0fc" stroke-width="4"/>
|
|
17
|
+
<path d="M40 60 C 60 20, 90 20, 80 60 S 110 100, 130 50 C 140 30, 160 40, 150 70 C 145 85, 170 85, 185 60" stroke="#2a0f26" stroke-width="4"/>
|
|
18
|
+
</svg>`;
|
|
19
|
+
stage.append(root);
|
|
20
|
+
const els = [...root.querySelectorAll('path, circle')];
|
|
21
|
+
els.forEach(el => { const L = el.getTotalLength(); el.style.strokeDasharray = L; el.style.strokeDashoffset = L; el.dataset.len = L; });
|
|
22
|
+
let timers = [];
|
|
23
|
+
function cycle() {
|
|
24
|
+
const each = PARAMS.duration / 2;
|
|
25
|
+
els.forEach((el, i) => el.animate([{ strokeDashoffset: el.dataset.len }, { strokeDashoffset: 0 }],
|
|
26
|
+
{ duration: each, delay: i * each * 0.25, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' }));
|
|
27
|
+
const drawn = each + els.length * each * 0.25;
|
|
28
|
+
timers.push(setTimeout(() => els.forEach((el, i) => el.animate([{ strokeDashoffset: 0 }, { strokeDashoffset: -el.dataset.len }],
|
|
29
|
+
{ duration: each * 0.6, delay: i * 60, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' })), drawn + 900));
|
|
30
|
+
timers.push(setTimeout(cycle, drawn + 900 + each * 0.6 + els.length * 60 + 300));
|
|
31
|
+
}
|
|
32
|
+
cycle();
|
|
33
|
+
return () => { timers.forEach(clearTimeout); root.remove(); };
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Liquid button: the outline is a ring of points on springs; the pointer pushes them as it passes.
|
|
2
|
+
const PARAMS = { wobble: 1, stiffness: 140 };
|
|
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;cursor:pointer';
|
|
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 N = 90;
|
|
14
|
+
const pts = Array.from({ length: N }, () => ({ off: 0, v: 0 }));
|
|
15
|
+
// point i on a rounded rectangle (a superellipse is close enough) and its outward normal
|
|
16
|
+
const base = i => {
|
|
17
|
+
const a = (i / N) * Math.PI * 2, bw = W * 0.3, bh = H * 0.15, n = 4;
|
|
18
|
+
const c = Math.cos(a), s = Math.sin(a);
|
|
19
|
+
const x = Math.sign(c) * Math.pow(Math.abs(c), 2 / n) * bw, y = Math.sign(s) * Math.pow(Math.abs(s), 2 / n) * bh;
|
|
20
|
+
return { x: W / 2 + x, y: H / 2 + y, nx: c, ny: s };
|
|
21
|
+
};
|
|
22
|
+
let px = -999, py = -999, lpx = px, lpy = py, lastMove = -1e9;
|
|
23
|
+
const onMove = e => { const b = canvas.getBoundingClientRect(); px = e.clientX - b.left; py = e.clientY - b.top; lastMove = performance.now(); };
|
|
24
|
+
canvas.addEventListener('pointermove', onMove);
|
|
25
|
+
let last = performance.now(), raf;
|
|
26
|
+
function frame(now) {
|
|
27
|
+
const dt = Math.min(0.025, (now - last) / 1000); last = now;
|
|
28
|
+
let x = px, y = py;
|
|
29
|
+
if (now - lastMove > 1500) { const t = now / 1000; x = W / 2 + Math.sin(t * 1.4) * W * 0.42; y = H / 2 + Math.sin(t * 2.8) * H * 0.2; }
|
|
30
|
+
const vx = (x - lpx) / Math.max(dt, 1e-3), vy = (y - lpy) / Math.max(dt, 1e-3); lpx = x; lpy = y;
|
|
31
|
+
const k = PARAMS.stiffness, c = 2 * Math.sqrt(k) * 0.32;
|
|
32
|
+
for (let i = 0; i < N; i++) {
|
|
33
|
+
const b = base(i), p = pts[i], d = Math.hypot(x - b.x, y - b.y), reach = Math.min(W, H) * 0.18;
|
|
34
|
+
if (d < reach) { const push = (1 - d / reach) ** 2 * (vx * b.nx + vy * b.ny) * 0.0007 * PARAMS.wobble; p.v += push * 60; }
|
|
35
|
+
p.v += (-k * p.off - c * p.v) * dt; p.off += p.v * dt;
|
|
36
|
+
}
|
|
37
|
+
// neighbours share their offset a little, so the wave travels round the edge
|
|
38
|
+
for (let i = 0; i < N; i++) { const a = pts[(i + N - 1) % N], b = pts[(i + 1) % N]; pts[i].v += ((a.off + b.off) / 2 - pts[i].off) * 4 * dt * 60 * 0.05; }
|
|
39
|
+
ctx.fillStyle = '#2a0f26'; ctx.fillRect(0, 0, W, H);
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
for (let i = 0; i <= N + 1; i++) {
|
|
42
|
+
const b = base(i % N), o = Math.max(-0.35, Math.min(0.35, pts[i % N].off)) * Math.min(W, H) * 0.22;
|
|
43
|
+
const X = b.x + b.nx * o, Y = b.y + b.ny * o;
|
|
44
|
+
i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y);
|
|
45
|
+
}
|
|
46
|
+
ctx.lineJoin = 'round'; ctx.fillStyle = '#f06595'; ctx.fill();
|
|
47
|
+
ctx.fillStyle = '#fff'; ctx.font = `700 ${Math.max(12, Math.min(W, H) * 0.075)}px "Mona Sans", system-ui, sans-serif`;
|
|
48
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Liquid', W / 2, H / 2);
|
|
49
|
+
raf = requestAnimationFrame(frame);
|
|
50
|
+
}
|
|
51
|
+
raf = requestAnimationFrame(frame);
|
|
52
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); canvas.remove(); };
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shape morph: every shape is stored as 160 distances from the centre, so any shape blends into any other.
|
|
2
|
+
const PARAMS = { hold: 600, morph: 700 };
|
|
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 NP = 160, TAU = Math.PI * 2;
|
|
14
|
+
// radius of a polygon along angle a: nearest edge hit by a ray from the centre
|
|
15
|
+
const polyR = verts => Array.from({ length: NP }, (_, i) => {
|
|
16
|
+
const a = (i / NP) * TAU, dx = Math.cos(a), dy = Math.sin(a); let best = Infinity;
|
|
17
|
+
for (let k = 0; k < verts.length; k++) {
|
|
18
|
+
const [x1, y1] = verts[k], [x2, y2] = verts[(k + 1) % verts.length], ex = x2 - x1, ey = y2 - y1;
|
|
19
|
+
const den = dx * ey - dy * ex; if (Math.abs(den) < 1e-9) continue;
|
|
20
|
+
const t = (x1 * ey - y1 * ex) / den, u = (x1 * dy - y1 * dx) / den;
|
|
21
|
+
if (t > 0 && u >= 0 && u <= 1) best = Math.min(best, t);
|
|
22
|
+
}
|
|
23
|
+
return best;
|
|
24
|
+
});
|
|
25
|
+
const ngon = (n, rOut, rIn = null, rot = -Math.PI / 2) => {
|
|
26
|
+
const v = [], m = rIn ? n * 2 : n;
|
|
27
|
+
for (let i = 0; i < m; i++) { const a = rot + (i / m) * TAU, r = rIn && i % 2 ? rIn : rOut; v.push([Math.cos(a) * r, Math.sin(a) * r]); }
|
|
28
|
+
return v;
|
|
29
|
+
};
|
|
30
|
+
const fn = f => Array.from({ length: NP }, (_, i) => f((i / NP) * TAU));
|
|
31
|
+
// scale every shape to the same root-mean-square radius so area feels constant
|
|
32
|
+
const norm = r => { const rms = Math.sqrt(r.reduce((s, x) => s + x * x, 0) / r.length); return r.map(x => x / rms); };
|
|
33
|
+
const shapes = [
|
|
34
|
+
norm(polyR([[-1, -1], [1, -1], [1, 1], [-1, 1]])),
|
|
35
|
+
norm(fn(() => 1)),
|
|
36
|
+
norm(polyR(ngon(5, 1, 0.45))),
|
|
37
|
+
norm(fn(a => 1 + 0.28 * Math.cos(6 * a))),
|
|
38
|
+
norm(polyR(ngon(3, 1))),
|
|
39
|
+
norm(fn(a => 1 + 0.16 * Math.sin(3 * a + 1) + 0.1 * Math.cos(5 * a))),
|
|
40
|
+
];
|
|
41
|
+
const colours = ['#b8359a', '#f06595', '#ff922b', '#e64980', '#c2255c', '#f76707'];
|
|
42
|
+
const ease = t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
43
|
+
let raf; const t0 = performance.now();
|
|
44
|
+
function frame(now) {
|
|
45
|
+
const step = PARAMS.hold + PARAMS.morph, el = now - t0, k = Math.floor(el / step) % shapes.length, local = (el % step - PARAMS.hold) / PARAMS.morph;
|
|
46
|
+
const p = ease(Math.max(0, Math.min(1, local))), a = shapes[k], b = shapes[(k + 1) % shapes.length];
|
|
47
|
+
const R = Math.min(W, H) * 0.3, rot = (k + p) * 0.35;
|
|
48
|
+
ctx.fillStyle = '#fbeefa'; ctx.fillRect(0, 0, W, H);
|
|
49
|
+
ctx.beginPath();
|
|
50
|
+
for (let i = 0; i <= NP; i++) {
|
|
51
|
+
const j = i % NP, r = (a[j] + (b[j] - a[j]) * p) * R, ang = (j / NP) * TAU + rot;
|
|
52
|
+
const x = W / 2 + Math.cos(ang) * r, y = H / 2 + Math.sin(ang) * r;
|
|
53
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
54
|
+
}
|
|
55
|
+
ctx.fillStyle = p > 0.5 ? colours[(k + 1) % colours.length] : colours[k];
|
|
56
|
+
ctx.fill();
|
|
57
|
+
raf = requestAnimationFrame(frame);
|
|
58
|
+
}
|
|
59
|
+
raf = requestAnimationFrame(frame);
|
|
60
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
|
|
61
|
+
}
|