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,294 @@
|
|
|
1
|
+
// The catalogue. Each entry points at anims/<cat>/<id>.js.
|
|
2
|
+
// `params` gives the slider range for each key in that file's PARAMS line.
|
|
3
|
+
|
|
4
|
+
import { MORE } from './registry-more.js';
|
|
5
|
+
|
|
6
|
+
export const CATEGORIES = [
|
|
7
|
+
{ id: 'basics', name: 'Basics', hue: '#3b5bdb' },
|
|
8
|
+
{ id: 'transitions', name: 'Page transitions', hue: '#0e9488' },
|
|
9
|
+
{ id: 'text', name: 'Text', hue: '#4f5d75' },
|
|
10
|
+
{ id: 'scroll', name: 'Scroll', hue: '#2f9e44' },
|
|
11
|
+
{ id: 'hover', name: 'Hover & cursor', hue: '#b07a00' },
|
|
12
|
+
{ id: 'micro', name: 'Micro & loading', hue: '#d63c74' },
|
|
13
|
+
{ id: 'springs', name: 'Springs & physics', hue: '#d9601a' },
|
|
14
|
+
{ id: 'morph', name: 'Morph & SVG', hue: '#b8359a' },
|
|
15
|
+
{ id: 'flow', name: 'Flow & particles', hue: '#0a87a6' },
|
|
16
|
+
{ id: 'fluid', name: 'Fluid', hue: '#2b74c9' },
|
|
17
|
+
{ id: 'chrome', name: 'Chrome & 3D', hue: '#8a6a4f' },
|
|
18
|
+
{ id: 'shaders', name: 'Shaders & glitch', hue: '#7b8c10' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const P = (min, max, step, label) => ({ min, max, step, label });
|
|
22
|
+
|
|
23
|
+
const BASE = [
|
|
24
|
+
// ── Basics
|
|
25
|
+
{ id: 'fade-scale', cat: 'basics', title: 'Fade & scale in', kind: 'dom', posterAt: 900,
|
|
26
|
+
trick: 'Cards rise from 94% scale, never from zero',
|
|
27
|
+
notes: ['Entering things start slightly small and transparent, then ease out hard: fast at first, gentle at the end.', 'Starting at scale 0.94 instead of 0 keeps the shape readable the whole way in.', 'The exit is shorter than the entrance; leaving should never make you wait.'],
|
|
28
|
+
params: { duration: P(150, 1200, 10, 'Duration (ms)'), startScale: P(0.5, 1, 0.01, 'Start scale'), stagger: P(0, 200, 5, 'Stagger (ms)') } },
|
|
29
|
+
{ id: 'stagger-grid', cat: 'basics', title: 'Stagger grid', kind: 'dom', posterAt: 420,
|
|
30
|
+
trick: 'Delay grows with distance from the origin cell',
|
|
31
|
+
notes: ['Each cell waits in proportion to its distance from a starting cell, so the grid reads as one ripple instead of 60 separate pops.', 'The origin moves every loop; the same rule makes a new pattern each time.', 'Keep the per-cell delay small (20–40 ms) or the wave feels sluggish.'],
|
|
32
|
+
params: { delayPerCell: P(5, 80, 1, 'Delay per cell (ms)'), cols: P(4, 16, 1, 'Columns') } },
|
|
33
|
+
{ id: 'easing-race', cat: 'basics', title: 'Easing curves race', kind: 'dom', posterAt: 1100,
|
|
34
|
+
trick: 'Same distance, same time, four different feels',
|
|
35
|
+
notes: ['All four dots travel the same distance in the same time; only the easing differs.', 'Linear feels mechanical, ease-in feels late, a strong ease-out feels instant, and a spring overshoots like something with mass.', 'Stiffness and damping only shape the spring lane.'],
|
|
36
|
+
params: { duration: P(400, 3000, 50, 'Duration (ms)'), stiffness: P(40, 400, 5, 'Spring stiffness'), damping: P(4, 40, 1, 'Spring damping') } },
|
|
37
|
+
{ id: 'card-flip', cat: 'basics', title: '3D card flip', kind: 'dom', posterAt: 1300,
|
|
38
|
+
trick: 'rotateY on a spring, with the back face hidden',
|
|
39
|
+
notes: ['The card has two faces glued back to back; backface-visibility hides whichever one faces away.', 'Perspective on the parent gives the turn real depth; lower values exaggerate it.', 'A spring drives the angle, so the flip overshoots a few degrees and settles.'],
|
|
40
|
+
params: { perspective: P(300, 2000, 10, 'Perspective (px)'), stiffness: P(40, 400, 5, 'Stiffness'), damping: P(4, 40, 1, 'Damping') } },
|
|
41
|
+
{ id: 'anticipation', cat: 'basics', title: 'Anticipation & follow-through', kind: 'canvas', posterAt: 650,
|
|
42
|
+
trick: 'Crouch before the jump, wobble after the landing',
|
|
43
|
+
notes: ['Before it moves, the blob squashes down, telling your eye where the energy is going.', 'In flight it stretches along its path; on landing its antenna keeps going and wobbles back (follow-through).', 'Remove the crouch and the same jump feels like it was pushed.'],
|
|
44
|
+
params: { anticipation: P(0, 0.6, 0.01, 'Crouch depth'), overshoot: P(0, 0.5, 0.01, 'Landing overshoot') } },
|
|
45
|
+
|
|
46
|
+
// ── Page transitions
|
|
47
|
+
{ id: 'push-slide', cat: 'transitions', title: 'Push slide', kind: 'dom', posterAt: 1500,
|
|
48
|
+
trick: 'The new page pushes; the old one drifts slower',
|
|
49
|
+
notes: ['The incoming page slides the full width while the outgoing one moves only 30% and dims: that difference reads as depth.', 'Both share one easing so they feel physically connected.', 'Enter and exit along the same axis, so going back reverses the motion.'],
|
|
50
|
+
params: { duration: P(200, 1500, 10, 'Duration (ms)'), parallax: P(0, 1, 0.05, 'Parallax') } },
|
|
51
|
+
{ id: 'circle-reveal', cat: 'transitions', title: 'Circle reveal', kind: 'dom', posterAt: 1700, pointer: true,
|
|
52
|
+
trick: 'clip-path circle grows from where you clicked',
|
|
53
|
+
notes: ['The next page sits on top, clipped to a circle of radius 0 at the click point; the radius grows until it covers the farthest corner.', 'Starting from the pointer ties the change to your action.', 'Click anywhere in the detail view to set the origin.'],
|
|
54
|
+
params: { duration: P(200, 2000, 10, 'Duration (ms)') } },
|
|
55
|
+
{ id: 'staircase-wipe', cat: 'transitions', title: 'Staircase wipe', kind: 'dom', posterAt: 880,
|
|
56
|
+
trick: 'Bars cover in a stagger, the page swaps unseen',
|
|
57
|
+
notes: ['Vertical bars sweep down one after another, cover the screen, and the content swaps while nothing is visible.', 'The bars then continue out the bottom, so the motion never reverses.', 'More bars with a smaller stagger feels smoother; fewer feels bolder.'],
|
|
58
|
+
params: { bars: P(2, 14, 1, 'Bars'), stagger: P(0, 160, 5, 'Stagger (ms)'), duration: P(200, 1200, 10, 'Bar duration (ms)') } },
|
|
59
|
+
{ id: 'shared-expand', cat: 'transitions', title: 'Shared-element expand', kind: 'dom', posterAt: 1400,
|
|
60
|
+
trick: 'FLIP: measure, jump, invert, play',
|
|
61
|
+
notes: ['FLIP: measure the tile, move it to its final layout, measure again, then transform it back to where it started and animate the transform to zero.', 'Only transform animates, so it stays smooth even though the layout changed.', 'Collapsing runs the same steps the other way.'],
|
|
62
|
+
params: { duration: P(200, 1500, 10, 'Duration (ms)') } },
|
|
63
|
+
{ id: 'view-transition', cat: 'transitions', title: 'View Transitions morph', kind: 'dom', posterAt: 1450,
|
|
64
|
+
trick: 'The browser morphs matching view-transition-names',
|
|
65
|
+
notes: ['document.startViewTransition() snapshots the page, runs your DOM change, then animates every element that shares a view-transition-name between its old and new box.', 'Here a list row becomes the header of its detail view.', 'Browsers without support simply swap instantly.'],
|
|
66
|
+
params: { duration: P(150, 1500, 10, 'Duration (ms)') } },
|
|
67
|
+
|
|
68
|
+
// ── Text
|
|
69
|
+
{ id: 'letter-rise', cat: 'text', title: 'Letter rise stagger', kind: 'dom', posterAt: 950,
|
|
70
|
+
trick: 'Each letter springs up 40 ms after the last',
|
|
71
|
+
notes: ['Every letter sits in a clipped line box and rises from below it on its own spring.', 'A small stagger (30–50 ms) makes the word read left to right without feeling slow.', 'Lower damping gives each letter a little bounce at the top.'],
|
|
72
|
+
params: { stagger: P(0, 150, 1, 'Stagger (ms)'), stiffness: P(60, 500, 5, 'Stiffness'), damping: P(6, 40, 1, 'Damping') } },
|
|
73
|
+
{ id: 'scramble', cat: 'text', title: 'Scramble decode', kind: 'dom', posterAt: 900,
|
|
74
|
+
trick: 'Random glyphs settle into the word, left to right',
|
|
75
|
+
notes: ['Each character cycles through random glyphs until its turn comes, then locks to the real letter.', 'Locking left to right keeps it readable; locking randomly feels more like hacking.', 'Use a monospaced or tabular face so the line does not jump while it scrambles.'],
|
|
76
|
+
params: { speed: P(10, 120, 1, 'Step (ms)'), settle: P(1, 12, 1, 'Steps per letter') } },
|
|
77
|
+
{ id: 'vf-wave', cat: 'text', title: 'Variable-font wave', kind: 'dom', posterAt: 800,
|
|
78
|
+
trick: 'A sine wave rolls through weight and width',
|
|
79
|
+
notes: ['A variable font has continuous weight and width axes, so each letter can sit anywhere between thin-condensed and black-wide.', 'Each letter reads a sine wave offset by its index, so the bulge travels along the word.', 'Only font-variation-settings changes; no layout tricks.'],
|
|
80
|
+
params: { amplitude: P(0, 1, 0.05, 'Amplitude'), speed: P(0.2, 4, 0.1, 'Speed'), spread: P(0.1, 1.5, 0.05, 'Spread') } },
|
|
81
|
+
{ id: 'typewriter', cat: 'text', title: 'Typewriter', kind: 'dom', posterAt: 1900,
|
|
82
|
+
trick: 'Jittered keystrokes, pauses on punctuation',
|
|
83
|
+
notes: ['Real typing is uneven: each delay is randomised around the base speed, and punctuation adds a pause.', 'The caret blinks only while idle; while typing it stays solid.', 'Deleting runs faster than typing, the way people backspace.'],
|
|
84
|
+
params: { cps: P(4, 40, 1, 'Characters / second'), jitter: P(0, 1, 0.05, 'Jitter') } },
|
|
85
|
+
{ id: 'line-mask', cat: 'text', title: 'Line mask reveal', kind: 'dom', posterAt: 1100,
|
|
86
|
+
trick: 'Lines slide up from behind a clipping edge',
|
|
87
|
+
notes: ['Each line lives in a box with overflow hidden, so its text can start fully below the edge and slide up into view.', 'Staggering the lines makes a paragraph feel typeset rather than faded in.', 'The strong ease-out does most of its travel in the first third.'],
|
|
88
|
+
params: { stagger: P(0, 250, 5, 'Stagger (ms)'), duration: P(200, 1500, 10, 'Duration (ms)') } },
|
|
89
|
+
|
|
90
|
+
// ── Scroll
|
|
91
|
+
{ id: 'parallax', cat: 'scroll', title: 'Parallax layers', kind: 'dom', posterAt: 1800, scroll: true,
|
|
92
|
+
trick: 'Far layers move less than near ones',
|
|
93
|
+
notes: ['Each layer moves by a fraction of the scroll distance: the far hills a little, the near ones a lot.', 'The difference in speed is what your eye reads as depth.', 'Scroll inside the stage in the detail view to drive it yourself.'],
|
|
94
|
+
params: { depth: P(0, 1.5, 0.05, 'Depth') } },
|
|
95
|
+
{ id: 'scrub-reveal', cat: 'scroll', title: 'Scrub reveal', kind: 'dom', posterAt: 2600, scroll: true,
|
|
96
|
+
trick: 'Each word brightens as the scroll passes it',
|
|
97
|
+
notes: ['Every word maps scroll progress to opacity, offset by its position in the paragraph.', 'Scrubbed means it follows the scroll exactly, forwards and backwards; nothing plays on its own.', 'A wider spread lights several words at once.'],
|
|
98
|
+
params: { spread: P(0.05, 1, 0.05, 'Spread') } },
|
|
99
|
+
{ id: 'pinned-track', cat: 'scroll', title: 'Pinned horizontal track', kind: 'dom', posterAt: 3000, scroll: true,
|
|
100
|
+
trick: 'Vertical scroll drives a sideways row',
|
|
101
|
+
notes: ['A tall section holds a sticky frame; while the frame is pinned, scroll progress translates the row of panels sideways.', 'The section height sets the pace: taller means slower travel.', 'position: sticky does the pinning, so no scroll-jacking.'],
|
|
102
|
+
params: { panels: P(3, 8, 1, 'Panels') } },
|
|
103
|
+
{ id: 'zoom-through', cat: 'scroll', title: 'Zoom-through', kind: 'dom', posterAt: 1700, scroll: true,
|
|
104
|
+
trick: 'Scale a letter until you pass through its counter',
|
|
105
|
+
notes: ['A word scales exponentially with scroll; exponential scale feels like a steady push forward, linear would feel like braking.', 'The transform origin sits inside the letter O, so you fly through its hole into the next scene.', 'Max scale sets how far you travel.'],
|
|
106
|
+
params: { maxScale: P(5, 60, 1, 'Max scale') } },
|
|
107
|
+
{ id: 'velocity-skew', cat: 'scroll', title: 'Velocity-skew marquee', kind: 'dom', posterAt: 2000, scroll: true,
|
|
108
|
+
trick: 'Scroll speed boosts the marquee and leans it',
|
|
109
|
+
notes: ['The marquee always drifts; scroll velocity is added on top and smoothed, so a fast scroll makes the rows race.', 'The same smoothed velocity skews the text, which reads as momentum.', 'When you stop, both decay back to the idle drift.'],
|
|
110
|
+
params: { skew: P(0, 0.6, 0.01, 'Skew'), boost: P(0, 15, 0.5, 'Boost') } },
|
|
111
|
+
|
|
112
|
+
// ── Hover & cursor
|
|
113
|
+
{ id: 'magnetic', cat: 'hover', title: 'Magnetic button', kind: 'dom', posterAt: 1200, pointer: true,
|
|
114
|
+
trick: 'Pulled toward the cursor, label pulled further',
|
|
115
|
+
notes: ['Within a radius the button moves a fraction of the way toward the pointer; its label moves even further, which gives a layered feel.', 'Both positions run through a spring, so release snaps back with a little life.', 'Keep the strength low or it feels like the button is running away.'],
|
|
116
|
+
params: { strength: P(0, 1, 0.05, 'Strength'), radius: P(40, 300, 5, 'Radius (px)') } },
|
|
117
|
+
{ id: 'ripple', cat: 'hover', title: 'Click ripple', kind: 'dom', posterAt: 450, pointer: true,
|
|
118
|
+
trick: 'A circle expands from the exact press point',
|
|
119
|
+
notes: ['The ripple starts exactly where you pressed, which confirms the click was heard and where.', 'It grows to reach the farthest corner, then fades out as it finishes.', 'Press feedback starts on pointerdown, not on release.'],
|
|
120
|
+
params: { duration: P(200, 1500, 10, 'Duration (ms)') } },
|
|
121
|
+
{ id: 'tilt', cat: 'hover', title: '3D tilt card', kind: 'dom', posterAt: 1000, pointer: true,
|
|
122
|
+
trick: 'The card turns toward the pointer, glare follows',
|
|
123
|
+
notes: ['Pointer position across the card maps to rotateX and rotateY, so the card turns toward you.', 'A soft glare moves opposite the tilt like light on a real surface.', 'Springs smooth the raw pointer so it feels heavy instead of twitchy.'],
|
|
124
|
+
params: { maxTilt: P(2, 30, 1, 'Max tilt (deg)'), stiffness: P(30, 400, 5, 'Stiffness') } },
|
|
125
|
+
{ id: 'cursor-trail', cat: 'hover', title: 'Cursor trail', kind: 'canvas', posterAt: 1400, pointer: true,
|
|
126
|
+
trick: 'Each point chases the one before it',
|
|
127
|
+
notes: ['The first point follows the cursor; every other point eases toward the point ahead of it.', 'That chain of lagging points draws a ribbon that tapers toward the tail.', 'A lower ease factor makes a longer, lazier trail.'],
|
|
128
|
+
params: { points: P(4, 60, 1, 'Points'), ease: P(0.05, 0.8, 0.01, 'Ease') } },
|
|
129
|
+
{ id: 'spotlight', cat: 'hover', title: 'Spotlight reveal', kind: 'dom', posterAt: 1300, pointer: true,
|
|
130
|
+
trick: 'A soft mask follows the cursor over a dark layer',
|
|
131
|
+
notes: ['A dark layer covers a colourful one; a radial mask cut out of the dark layer follows the pointer.', 'The mask position is smoothed so the light trails slightly behind.', 'Mask-image keeps it all in CSS: no canvas needed.'],
|
|
132
|
+
params: { radius: P(40, 300, 5, 'Radius (px)') } },
|
|
133
|
+
|
|
134
|
+
// ── Micro & loading
|
|
135
|
+
{ id: 'toggle', cat: 'micro', title: 'Toggle spring', kind: 'dom', posterAt: 700,
|
|
136
|
+
trick: 'The knob stretches while it travels',
|
|
137
|
+
notes: ['The knob runs on a spring and stretches in proportion to its speed, then snaps round when it lands.', 'The track colour follows the knob position, not a timer, so they never disagree.', 'Click it in the detail view; it is a real button.'],
|
|
138
|
+
params: { stiffness: P(80, 800, 10, 'Stiffness'), damping: P(8, 50, 1, 'Damping') } },
|
|
139
|
+
{ id: 'like-burst', cat: 'micro', title: 'Like burst', kind: 'dom', posterAt: 640,
|
|
140
|
+
trick: 'Squash, pop, ring and sparks on one beat',
|
|
141
|
+
notes: ['The heart squashes on press, overshoots to 120% and settles, while a ring and a burst of dots fire from its centre.', 'Everything starts on the same frame: that synchrony is what makes it feel like one event.', 'The sparks use their own random angles each time.'],
|
|
142
|
+
params: { particles: P(4, 24, 1, 'Sparks'), spread: P(0.4, 2, 0.05, 'Spread') } },
|
|
143
|
+
{ id: 'submit-success', cat: 'micro', title: 'Submit to success', kind: 'dom', posterAt: 1700,
|
|
144
|
+
trick: 'The button becomes the spinner becomes the tick',
|
|
145
|
+
notes: ['The button morphs into a circle, spins while waiting, then draws a tick, so the control itself shows each state.', 'The width animates with a strong ease-out; the tick draws with stroke-dashoffset.', 'One object changing shape beats three swapping in and out.'],
|
|
146
|
+
params: { duration: P(150, 1000, 10, 'Morph (ms)') } },
|
|
147
|
+
{ id: 'arc-spinner', cat: 'micro', title: 'Arc spinner', kind: 'dom', posterAt: 600,
|
|
148
|
+
trick: 'The arc grows and shrinks while it turns',
|
|
149
|
+
notes: ['Two things run at once: the whole ring rotates, and the stroke dash grows and shrinks.', 'Their periods do not match, so the arc never repeats the same way twice in a row.', 'A faster spinner makes waiting feel shorter.'],
|
|
150
|
+
params: { speed: P(0.25, 3, 0.05, 'Speed') } },
|
|
151
|
+
{ id: 'skeleton', cat: 'micro', title: 'Skeleton shimmer', kind: 'dom', posterAt: 900,
|
|
152
|
+
trick: 'A light sweep says loading, then content fades in',
|
|
153
|
+
notes: ['Placeholder shapes match the real layout, so nothing jumps when content arrives.', 'A soft highlight sweeps across them to signal work in progress.', 'The real content cross-fades in over the skeleton.'],
|
|
154
|
+
params: { speed: P(0.4, 4, 0.1, 'Shimmer speed') } },
|
|
155
|
+
|
|
156
|
+
// ── Springs & physics
|
|
157
|
+
{ id: 'squash-bounce', cat: 'springs', title: 'Squash & stretch bounce', kind: 'canvas', posterAt: 700,
|
|
158
|
+
trick: 'Stretch with speed, squash on impact',
|
|
159
|
+
notes: ['In flight the ball stretches along its velocity; on impact it squashes while keeping its area, so it reads as soft, not shrinking.', 'Each bounce keeps a fraction of its energy (bounce).', 'The shadow grows and darkens as the ball comes down.'],
|
|
160
|
+
params: { gravity: P(600, 5000, 50, 'Gravity'), bounce: P(0.3, 0.95, 0.01, 'Bounce'), squash: P(0, 0.7, 0.01, 'Squash') } },
|
|
161
|
+
{ id: 'rubber-band', cat: 'springs', title: 'Rubber-band drag', kind: 'dom', posterAt: 1500, pointer: true,
|
|
162
|
+
trick: 'Resistance grows past the edge, release springs back',
|
|
163
|
+
notes: ['Inside the track the card follows the pointer 1:1. Past the edge it follows less and less (Apple\'s rubber-band formula).', 'On release, a spring returns it, starting with the speed your hand had.', 'Drag it in the detail view.'],
|
|
164
|
+
params: { constant: P(0.1, 1, 0.01, 'Resistance'), stiffness: P(60, 600, 10, 'Return stiffness') } },
|
|
165
|
+
{ id: 'pendulum-wave', cat: 'springs', title: 'Pendulum wave', kind: 'canvas', posterAt: 4000,
|
|
166
|
+
trick: 'Lengths tuned so the row drifts in and out of sync',
|
|
167
|
+
notes: ['Each pendulum swings a whole number of times more than its neighbour per cycle.', 'So they start together, drift into travelling waves and snakes, and line up again at the end of the cycle.', 'Pure maths, no physics step: angle = A·cos(2π·n·t / cycle).'],
|
|
168
|
+
params: { count: P(5, 30, 1, 'Pendulums'), cycle: P(10, 60, 1, 'Cycle (s)') } },
|
|
169
|
+
{ id: 'verlet-rope', cat: 'springs', title: 'Verlet rope', kind: 'canvas', posterAt: 1500, pointer: true,
|
|
170
|
+
trick: 'Points remember where they were; lengths are enforced',
|
|
171
|
+
notes: ['Verlet integration moves each point by its last motion plus gravity, with no velocity stored.', 'Then the solver nudges neighbours back to their rest length a few times per frame.', 'More iterations make a stiffer rope.'],
|
|
172
|
+
params: { segments: P(6, 50, 1, 'Segments'), gravity: P(0, 3000, 50, 'Gravity'), iterations: P(1, 30, 1, 'Iterations') } },
|
|
173
|
+
{ id: 'ball-collisions', cat: 'springs', title: 'Ball collisions', kind: 'canvas', posterAt: 1200,
|
|
174
|
+
trick: 'Elastic impulses along the line between centres',
|
|
175
|
+
notes: ['When two balls overlap they are pushed apart, then swap the part of their velocity along the line between their centres.', 'Heavier (bigger) balls keep more of their momentum.', 'Restitution below 1 loses a little energy each hit.'],
|
|
176
|
+
params: { count: P(4, 40, 1, 'Balls'), restitution: P(0.5, 1, 0.01, 'Restitution') } },
|
|
177
|
+
|
|
178
|
+
// ── Morph & SVG
|
|
179
|
+
{ id: 'shape-morph', cat: 'morph', title: 'Shape morph', kind: 'canvas', posterAt: 1500,
|
|
180
|
+
trick: 'Every shape is the same 160 polar radii',
|
|
181
|
+
notes: ['Every shape is stored as 160 distances from the centre at fixed angles, so any shape can blend into any other point by point.', 'Interpolating radii keeps the outline smooth with no tangles.', 'Square, circle, star, flower and blob share one list.'],
|
|
182
|
+
params: { hold: P(0, 2000, 50, 'Hold (ms)'), morph: P(150, 2000, 10, 'Morph (ms)') } },
|
|
183
|
+
{ id: 'icon-morph', cat: 'morph', title: 'Icon morph', kind: 'dom', posterAt: 900,
|
|
184
|
+
trick: 'Two paths with the same points, interpolated',
|
|
185
|
+
notes: ['Play and pause, menu and close: each pair is drawn with the same number of points in the same order.', 'Blending the numbers point by point turns one icon into the other.', 'A little rotation during the morph hides the in-between shapes.'],
|
|
186
|
+
params: { duration: P(100, 1200, 10, 'Duration (ms)') } },
|
|
187
|
+
{ id: 'gooey', cat: 'morph', title: 'Gooey blobs', kind: 'dom', posterAt: 1300,
|
|
188
|
+
trick: 'Blur, then a hard alpha threshold',
|
|
189
|
+
notes: ['The blobs are blurred together, then an SVG colour-matrix filter cranks alpha contrast, so the soft overlap becomes a hard liquid bridge.', 'More blur means blobs merge from farther away.', 'The circles themselves are plain divs.'],
|
|
190
|
+
params: { blur: P(4, 30, 1, 'Blur'), blobs: P(2, 10, 1, 'Blobs') } },
|
|
191
|
+
{ id: 'line-draw', cat: 'morph', title: 'Line draw-on', kind: 'dom', posterAt: 1700,
|
|
192
|
+
trick: 'stroke-dasharray equals the path length',
|
|
193
|
+
notes: ['Set the dash length to the full path length, offset the dash by the same amount and the line disappears; animate the offset to zero and it draws itself.', 'getTotalLength() measures each path.', 'Several paths staggered read like a hand drawing.'],
|
|
194
|
+
params: { duration: P(500, 6000, 50, 'Duration (ms)') } },
|
|
195
|
+
{ id: 'liquid-button', cat: 'morph', title: 'Liquid button', kind: 'canvas', posterAt: 1300, pointer: true,
|
|
196
|
+
trick: 'Edge points on springs, pushed by the pointer',
|
|
197
|
+
notes: ['The button outline is a ring of points, each on its own spring back to the rounded rectangle.', 'The pointer pushes nearby points as it passes, and the springs make the edge wobble like liquid.', 'Lower stiffness gives a gloopier surface.'],
|
|
198
|
+
params: { wobble: P(0, 3, 0.05, 'Push'), stiffness: P(30, 400, 5, 'Stiffness') } },
|
|
199
|
+
|
|
200
|
+
// ── Flow & particles
|
|
201
|
+
{ id: 'curl-flow', cat: 'flow', title: 'Curl-noise flow field', kind: 'canvas', posterAt: 2200,
|
|
202
|
+
trick: 'A divergence-free field, so particles never clump',
|
|
203
|
+
notes: ['Each particle moves along the curl of a smooth noise field, which has no sources or sinks, so streams swirl without piling up.', 'Drawing a translucent rectangle each frame instead of clearing leaves the trails.', 'Scale sets the size of the eddies.'],
|
|
204
|
+
params: { count: P(500, 8000, 100, 'Particles'), speed: P(0.2, 3, 0.1, 'Speed'), scale: P(0.0008, 0.006, 0.0001, 'Field scale') } },
|
|
205
|
+
{ id: 'drone-show', cat: 'flow', title: 'Drone light show', kind: 'webgl', posterAt: 19000,
|
|
206
|
+
trick: 'Every formation change is an assignment problem, solved by sorting and swaps',
|
|
207
|
+
notes: ['A fleet of drones lifts off a barge and flies formations over a night city: a turning globe, Saturn and its ring, a beating heart, a word. Each formation is a list of slots, and every drone needs its own.', 'To keep the sky clean, slots are matched to drones by sorting both into bands, then thousands of random swap tests each keep a swap only if it shortens the total flight, so no drone crosses the sky. Launch and landing paths curve: climb first, then swing over.', 'The bay mirrors everything at the waterline; three swells break the reflection up and stretch it toward you, and a bloom chain gives every light its halo.'],
|
|
208
|
+
params: { drones: P(300, 3000, 100, 'Drones'), speed: P(0.3, 2, 0.1, 'Show speed'), glow: P(0, 2, 0.1, 'Glow') } },
|
|
209
|
+
{ id: 'particle-life', cat: 'flow', title: 'Particle life', kind: 'canvas', posterAt: 15000, pointer: true,
|
|
210
|
+
trick: 'Six species, one table of likes and dislikes, and creatures appear',
|
|
211
|
+
notes: ['Every pair of particles pushes apart when very close, then attracts or repels by an entry in a small table (how much species A likes species B), fading out at a fixed range.', 'The table is not symmetric: red may chase green while green flees red. Out of that come things that look alive: cells with membranes, worms, spinners, swarms that hunt each other.', 'Every half minute the table drifts to a new random one and the creatures melt and different ones form. Neighbours come from a grid, the box wraps, and you can drag to stir.'],
|
|
212
|
+
params: { particles: P(600, 4000, 100, 'Particles'), species: P(2, 8, 1, 'Species'), range: P(0.05, 0.16, 0.005, 'Range') } },
|
|
213
|
+
{ id: 'cloud-chamber', cat: 'flow', title: 'Cloud chamber', kind: 'canvas', posterAt: 6500,
|
|
214
|
+
trick: 'Alpha stubs, spiralling electrons and straight muons in supersaturated mist',
|
|
215
|
+
notes: ['The alcohol vapour in the chamber is supersaturated, so any charged particle that passes leaves a trail of droplets, lit from the side. A thoriated welding rod sends out alpha particles: short, fat, dead-straight tracks.', 'Beta electrons are light, so they scatter and wander, and in the magnet under the chamber they curl into spirals that tighten as they slow; now and then one curls the other way, a positron. Cosmic-ray muons cut straight across, sometimes knocking loose a small electron of their own.', 'Each track is a list of droplets that swell, spread, drift with the convection and fade. Mist drifts through the light and a fine drizzle falls.'],
|
|
216
|
+
params: { activity: P(0.2, 3, 0.1, 'Activity'), field: P(0, 2.5, 0.05, 'Magnetic field'), mist: P(0, 2, 0.05, 'Mist') } },
|
|
217
|
+
{ id: 'holi', cat: 'flow', title: 'Holi powder', kind: 'webgl', posterAt: 6000,
|
|
218
|
+
trick: 'Puffs that shade each other through a coarse 3D density grid',
|
|
219
|
+
notes: ['Handfuls of coloured powder are thrown up into a bright sky. Each burst is a thousand soft puffs that drag to a stop, swell and swirl in turbulence, drifting with the breeze.', 'To read as clouds rather than flat blobs, the puffs shade each other: every frame their density is splatted into a coarse 3D grid, light is marched through it from the sun, and each puff is lit by what gets through.', 'The sun sits behind the powder, so thin edges glow and thick cores go dusky; puffs are sorted far to near and drawn over one another with a lumpy noise texture.'],
|
|
220
|
+
params: { bursts: P(0.3, 2.5, 0.1, 'Bursts'), puffs: P(300, 2000, 50, 'Puffs per burst'), wind: P(-2, 3, 0.1, 'Breeze') } },
|
|
221
|
+
|
|
222
|
+
// ── Fluid
|
|
223
|
+
{ id: 'ink-fluid', cat: 'fluid', title: 'Ink fluid', kind: 'webgl', posterAt: 2500, pointer: true,
|
|
224
|
+
trick: 'Stable fluids: advect, curl, project, repeat',
|
|
225
|
+
notes: ['A real Navier–Stokes solver on the GPU: velocity carries the dye, a vorticity step keeps the swirls alive, and a pressure solve keeps it incompressible.', 'Move across it to push ink; it also splats on its own.', 'Curl adds small swirls; dissipation sets how long the ink lasts.'],
|
|
226
|
+
params: { curl: P(0, 60, 1, 'Curl'), dissipation: P(0.9, 1, 0.002, 'Dye persistence'), splatRadius: P(0.05, 0.8, 0.01, 'Splat radius') } },
|
|
227
|
+
{ id: 'ripples', cat: 'fluid', title: 'Water ripples', kind: 'canvas', posterAt: 1600, pointer: true,
|
|
228
|
+
trick: 'Two height buffers and the wave equation',
|
|
229
|
+
notes: ['Two height maps swap each frame; each cell becomes the average of its neighbours minus its old value, which is the wave equation.', 'Shading uses the slope of the surface, so ripples catch light.', 'Damping below 1 lets the waves die out.'],
|
|
230
|
+
params: { damping: P(0.9, 0.995, 0.001, 'Damping'), dropRate: P(0, 5, 0.1, 'Drops / second') } },
|
|
231
|
+
{ id: 'liquid-distort', cat: 'fluid', title: 'Liquid image distortion', kind: 'webgl', posterAt: 1500, pointer: true,
|
|
232
|
+
trick: 'Offset texture lookups by flowing noise',
|
|
233
|
+
notes: ['The picture is a texture; each pixel samples it from a slightly shifted spot, with the shift taken from animated noise.', 'Near the pointer the shift grows, like a finger dragged through water.', 'Strength is how far pixels can be displaced.'],
|
|
234
|
+
params: { strength: P(0, 0.12, 0.002, 'Strength'), speed: P(0, 1.5, 0.05, 'Flow speed') } },
|
|
235
|
+
{ id: 'lava-lamp', cat: 'fluid', title: 'Lava lamp', kind: 'webgl', posterAt: 2000,
|
|
236
|
+
trick: 'Metaballs: sum the fields, threshold the sum',
|
|
237
|
+
notes: ['Each blob adds a field that falls off with distance; wherever the total passes a threshold, the pixel is lava.', 'Because fields add, blobs bulge toward each other and merge smoothly.', 'They rise and sink on slow sine paths.'],
|
|
238
|
+
params: { blobs: P(2, 14, 1, 'Blobs'), speed: P(0.1, 2, 0.05, 'Speed') } },
|
|
239
|
+
{ id: 'ocean-waves', cat: 'fluid', title: 'Ocean waves', kind: 'canvas', posterAt: 1200,
|
|
240
|
+
trick: 'Gerstner waves: points move in circles',
|
|
241
|
+
notes: ['In a Gerstner wave the water moves in circles, not just up and down, which sharpens the crests and flattens the troughs.', 'Several wave layers at different speeds and depths add parallax.', 'Choppiness sets how sharp the crests get.'],
|
|
242
|
+
params: { height: P(0.2, 2, 0.05, 'Height'), choppiness: P(0, 1, 0.05, 'Choppiness') } },
|
|
243
|
+
|
|
244
|
+
// ── Chrome & 3D
|
|
245
|
+
{ id: 'portal', cat: 'chrome', title: 'Portal', kind: 'webgl', posterAt: 3000,
|
|
246
|
+
trick: 'A ray that passes through the ring finishes its journey in the other world',
|
|
247
|
+
notes: ['A stone ring stands on a dune, and through it you see a night sea under a ringed planet. It is not a picture on a disc: every ray is traced through the desert, and if it passes through the ring’s opening before hitting anything, the rest of its journey is traced in the other world, from that point, in the same direction.', 'So the view through the portal shifts with perspective as the camera moves. When the camera itself flies through, the worlds swap: the ring now stands on the sea and shows the desert. Then it turns round and flies back.', 'Both worlds are raymarched height fields (dunes; a sea of three swells reflecting the sky), lit by their own suns, with a glowing inner edge on the ring.'],
|
|
248
|
+
params: { speed: P(0.3, 2, 0.1, 'Speed'), glow: P(0, 2, 0.05, 'Ring glow'), waves: P(0, 2.5, 0.05, 'Waves') } },
|
|
249
|
+
{ id: 'geode', cat: 'chrome', title: 'Geode', kind: 'webgl', posterAt: 6500,
|
|
250
|
+
trick: 'A Voronoi pattern on the sphere of directions grows the crystals inward',
|
|
251
|
+
notes: ['A rough grey stone cracks in half and opens like a book. It is one distance function: a sphere roughened with noise; each half keeps one side of the cut and has a hollow carved out of it.', 'The hollow’s wall is pushed inward by a Voronoi pattern over directions, measured with a squarish metric so every cell becomes a faceted crystal pointing at the centre: deep purple at the root, pale at the tip, facets glinting one by one.', 'On the cut face you see the layers in section: the rind, the stone, bands of agate, then the crystal lining.'],
|
|
252
|
+
params: { crystals: P(0.6, 1.8, 0.05, 'Crystal size'), sparkle: P(0, 2.5, 0.1, 'Sparkle'), speed: P(0.4, 2, 0.1, 'Speed') } },
|
|
253
|
+
{ id: 'glass-lens', cat: 'chrome', title: 'Glass lens refraction', kind: 'webgl', posterAt: 1400, pointer: true,
|
|
254
|
+
trick: 'Bend the lookup by the lens normal, per colour',
|
|
255
|
+
notes: ['Inside the lens each pixel samples the scene behind it, offset by the lens surface normal, which is refraction.', 'Red, green and blue are offset by slightly different amounts, giving the coloured fringe at the edge (dispersion).', 'The lens follows the pointer.'],
|
|
256
|
+
params: { ior: P(1, 2, 0.01, 'Refraction'), dispersion: P(0, 0.08, 0.002, 'Dispersion') } },
|
|
257
|
+
{ id: 'dust-bust', cat: 'chrome', title: 'Dust to the wind', kind: 'webgl', posterAt: 5600,
|
|
258
|
+
trick: 'A dissolving front frees each surface point into a swirling wind',
|
|
259
|
+
notes: ['A marble bust made of a hundred and fifty thousand points, each sitting on the surface with its own normal so it lights like stone. The surface is sampled from simple shapes (an egg of a head, nose, brow, ears, a bun, neck, shoulders), keeping only points no other shape covers.', 'A dissolving front sweeps across from one shoulder. Every point it passes flares like an ember, breaks loose and is carried off by a swirling wind, cooling to grey dust.', 'Then the dust is called back along curving paths and the bust stands whole again.'],
|
|
260
|
+
params: { points: P(30000, 250000, 10000, 'Points'), wind: P(0.3, 2.5, 0.1, 'Wind'), speed: P(0.4, 2, 0.1, 'Speed') } },
|
|
261
|
+
{ id: 'cloth-reveal', cat: 'chrome', title: 'Cloth reveal', kind: 'webgl', posterAt: 3000,
|
|
262
|
+
trick: 'Verlet cloth with stretch, shear and bend constraints drapes, then is whipped off',
|
|
263
|
+
notes: ['A silk sheet falls over something on a plinth, drapes, and is whipped off by one corner to unveil a chrome sculpture.', 'The sheet is a grid of points joined by distance constraints: neighbours, diagonals, and every second point, so it resists folding too sharply. Points move by Verlet integration, the constraints are solved ten times a frame, and the cloth collides with the sculpture, the plinth and the floor, with friction.', 'Silk is lit two-sided with a soft sheen that runs along the folds; the sculpture reflects a studio of softboxes.'],
|
|
264
|
+
params: { grid: P(20, 64, 2, 'Cloth resolution'), stiffness: P(0.3, 1, 0.05, 'Stiffness'), speed: P(0.4, 2, 0.1, 'Speed') } },
|
|
265
|
+
|
|
266
|
+
// ── Shaders & glitch
|
|
267
|
+
{ id: 'aurora', cat: 'shaders', title: 'Aurora gradient', kind: 'webgl', posterAt: 1500,
|
|
268
|
+
trick: 'Noise warped by noise, mapped to a palette',
|
|
269
|
+
notes: ['Domain warping: the noise coordinates are themselves pushed around by another noise, which gives slow folding curtains.', 'The result picks colours from a small palette rather than a rainbow.', 'Warp sets how much the curtains fold.'],
|
|
270
|
+
params: { speed: P(0, 1.5, 0.05, 'Speed'), warp: P(0, 3, 0.05, 'Warp') } },
|
|
271
|
+
{ id: 'voronoi', cat: 'shaders', title: 'Voronoi cells', kind: 'webgl', posterAt: 1200,
|
|
272
|
+
trick: 'Nearest point wins; the gap to the second is the edge',
|
|
273
|
+
notes: ['Every pixel finds the nearest of a set of moving points; that point owns the cell.', 'The difference between the nearest and second-nearest distance gives clean edges.', 'Cell count sets the density.'],
|
|
274
|
+
params: { cells: P(2, 20, 1, 'Cells'), speed: P(0, 2, 0.05, 'Speed') } },
|
|
275
|
+
{ id: 'reaction-diffusion', cat: 'shaders', title: 'Reaction–diffusion', kind: 'webgl', posterAt: 3000,
|
|
276
|
+
trick: 'Gray–Scott: two chemicals, feed and kill',
|
|
277
|
+
notes: ['Two simulated chemicals spread at different rates and one eats the other; the balance of feed and kill rates decides the pattern.', 'Small changes flip it between spots, stripes and coral.', 'The simulation runs on the GPU, several steps per frame.'],
|
|
278
|
+
params: { feed: P(0.025, 0.05, 0.001, 'Feed'), kill: P(0.058, 0.0655, 0.0005, 'Kill') } },
|
|
279
|
+
{ id: 'guilloche', cat: 'shaders', title: 'Guilloche spirograph', kind: 'canvas', posterAt: 3200,
|
|
280
|
+
trick: 'A hypotrochoid, drawn as a seven-line ribbon',
|
|
281
|
+
notes: ['A hypotrochoid is a point on a small circle rolling inside a big one; whole-number ratios close the loop.', 'Drawing seven slightly offset copies gives the engraved banknote look.', 'Change R, r and d for completely different rosettes.'],
|
|
282
|
+
params: { R: P(3, 12, 1, 'Big circle'), r: P(1, 9, 1, 'Small circle'), d: P(1, 9, 0.1, 'Pen offset'), lines: P(1, 12, 1, 'Lines') } },
|
|
283
|
+
{ id: 'rgb-glitch', cat: 'shaders', title: 'RGB-split glitch', kind: 'canvas', posterAt: 1250,
|
|
284
|
+
trick: 'Shift the colour channels, displace slices',
|
|
285
|
+
notes: ['The picture is drawn three times, one per colour channel, with additive blending; offsetting them splits the colour.', 'During bursts, horizontal slices jump sideways and scanlines jitter.', 'Bursts arrive at random so the eye cannot predict them.'],
|
|
286
|
+
params: { intensity: P(0, 2, 0.05, 'Intensity'), rate: P(0, 3, 0.05, 'Bursts / second') } },
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
// round 2 additions live in registry-more.js; keep every category together, in dial order
|
|
290
|
+
const order = Object.fromEntries(CATEGORIES.map((c, i) => [c.id, i]));
|
|
291
|
+
export const ANIMS = [...BASE, ...MORE].sort((a, b) => order[a.cat] - order[b.cat]);
|
|
292
|
+
export const catById = Object.fromEntries(CATEGORIES.map(c => [c.id, c]));
|
|
293
|
+
export const animById = Object.fromEntries(ANIMS.map(a => [a.id, a]));
|
|
294
|
+
export const srcOf = a => `anims/${a.cat}/${a.id}.js`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Parallax layers: far layers move a little, near layers a lot, so scrolling reads as depth.
|
|
2
|
+
const PARAMS = { depth: 0.6 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'px-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.px-root { position: absolute; inset: 0; }
|
|
9
|
+
.px-scroll { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
|
|
10
|
+
.px-scroll::-webkit-scrollbar { display: none; }
|
|
11
|
+
.px-scene { position: sticky; top: 0; overflow: hidden; background: #d3f9d8; }
|
|
12
|
+
.px-layer { position: absolute; left: -5%; width: 110%; will-change: transform; }
|
|
13
|
+
.px-layer svg { display: block; width: 100%; height: 100%; }
|
|
14
|
+
.px-sun { position: absolute; width: 16%; aspect-ratio: 1; border-radius: 50%; background: #fab005; left: 66%; top: 12%; will-change: transform; }
|
|
15
|
+
.px-word { position: absolute; left: 7%; top: 12%; font: 800 9vmin/1 "Mona Sans", system-ui, sans-serif; color: #1b4d2b; letter-spacing: -0.02em; will-change: transform; }
|
|
16
|
+
</style>
|
|
17
|
+
<div class="px-scroll" data-scroll><div class="px-track"><div class="px-scene"><div class="px-sun"></div><div class="px-word">Scroll down</div></div></div></div>`;
|
|
18
|
+
stage.append(root);
|
|
19
|
+
const sc = root.querySelector('.px-scroll'), scene = root.querySelector('.px-scene');
|
|
20
|
+
const H = stage.clientHeight;
|
|
21
|
+
scene.style.height = H + 'px';
|
|
22
|
+
root.querySelector('.px-track').style.height = H * 4 + 'px';
|
|
23
|
+
|
|
24
|
+
// Hills from far to near. `top` is where the crest starts (fraction of the frame);
|
|
25
|
+
// `speed` is how far the layer rises over the whole scroll, as a fraction of the frame height.
|
|
26
|
+
const hills = [
|
|
27
|
+
{ c: '#b2f2bb', top: 0.5, amp: 0.05, f: 1.3, speed: 0.12 },
|
|
28
|
+
{ c: '#69db7c', top: 0.62, amp: 0.06, f: 2.1, speed: 0.3 },
|
|
29
|
+
{ c: '#37b24d', top: 0.74, amp: 0.05, f: 3.2, speed: 0.55 },
|
|
30
|
+
{ c: '#1b5e2c', top: 0.88, amp: 0.04, f: 4.6, speed: 0.9 },
|
|
31
|
+
];
|
|
32
|
+
const layers = hills.map((L, i) => {
|
|
33
|
+
const pts = []; for (let x = 0; x <= 100; x += 2) pts.push(`${x},${(L.amp * (1 + Math.sin(x / 100 * Math.PI * L.f + i))) * 50}`);
|
|
34
|
+
const el = document.createElement('div'); el.className = 'px-layer';
|
|
35
|
+
el.style.top = (L.top * H) + 'px'; el.style.height = (H * 1.2) + 'px';
|
|
36
|
+
el.innerHTML = `<svg viewBox="0 0 100 120" preserveAspectRatio="none"><polygon fill="${L.c}" points="0,120 ${pts.join(' ')} 100,120"/></svg>`;
|
|
37
|
+
scene.append(el); return { el, speed: L.speed };
|
|
38
|
+
});
|
|
39
|
+
const sun = root.querySelector('.px-sun'), word = root.querySelector('.px-word');
|
|
40
|
+
let raf;
|
|
41
|
+
function frame() {
|
|
42
|
+
const max = sc.scrollHeight - sc.clientHeight, p = max > 0 ? sc.scrollTop / max : 0, d = PARAMS.depth;
|
|
43
|
+
sun.style.transform = `translateY(${p * H * 0.1 * d}px)`;
|
|
44
|
+
layers.forEach(L => { L.el.style.transform = `translateY(${-p * H * L.speed * d}px)`; });
|
|
45
|
+
word.style.transform = `translateY(${-p * H * 1.4 * d}px)`;
|
|
46
|
+
raf = requestAnimationFrame(frame);
|
|
47
|
+
}
|
|
48
|
+
raf = requestAnimationFrame(frame);
|
|
49
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
50
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pinned horizontal track: a sticky frame holds still while vertical scroll slides a row of panels sideways.
|
|
2
|
+
const PARAMS = { panels: 5 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const names = ['Sketch', 'Block', 'Spline', 'Polish', 'Render', 'Review', 'Deliver', 'Archive'];
|
|
6
|
+
const colours = ['#2f9e44', '#fab005', '#15171a', '#ff8787', '#4dabf7', '#a9e34b', '#e64980', '#868e96'];
|
|
7
|
+
const n = Math.round(PARAMS.panels);
|
|
8
|
+
const root = document.createElement('div');
|
|
9
|
+
root.className = 'pt-root';
|
|
10
|
+
root.innerHTML = `<style>
|
|
11
|
+
.pt-root { position: absolute; inset: 0; background: #ebfbee; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
12
|
+
.pt-scroll { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
|
|
13
|
+
.pt-scroll::-webkit-scrollbar { display: none; }
|
|
14
|
+
.pt-frame { position: sticky; top: 0; overflow: hidden; display: flex; align-items: center; }
|
|
15
|
+
.pt-row { display: flex; gap: 4vmin; padding: 0 8vmin; will-change: transform; }
|
|
16
|
+
.pt-panel { flex: none; width: 62vmin; aspect-ratio: 4 / 3; border-radius: 3vmin; display: flex; flex-direction: column; justify-content: space-between; padding: 4vmin; box-sizing: border-box; color: #fff; }
|
|
17
|
+
.pt-panel b { font-size: 11vmin; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
|
18
|
+
.pt-panel span { font-size: max(11px, 3.6vmin); font-weight: 600; opacity: .85; font-variant-numeric: tabular-nums; }
|
|
19
|
+
.pt-panel.light { color: #15171a; }
|
|
20
|
+
</style><div class="pt-scroll" data-scroll><div class="pt-track"><div class="pt-frame"><div class="pt-row"></div></div></div></div>`;
|
|
21
|
+
stage.append(root);
|
|
22
|
+
const sc = root.querySelector('.pt-scroll'), frame = root.querySelector('.pt-frame'), row = root.querySelector('.pt-row');
|
|
23
|
+
const H = stage.clientHeight;
|
|
24
|
+
frame.style.height = H + 'px';
|
|
25
|
+
root.querySelector('.pt-track').style.height = H * (1 + n * 0.8) + 'px';
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
const el = document.createElement('div'); el.className = 'pt-panel' + ([1, 3, 5].includes(i) ? ' light' : '');
|
|
28
|
+
el.style.background = colours[i % colours.length];
|
|
29
|
+
el.innerHTML = `<span>Step ${i + 1} of ${n}</span><b>${names[i % names.length]}</b>`; row.append(el);
|
|
30
|
+
}
|
|
31
|
+
let raf;
|
|
32
|
+
function tick() {
|
|
33
|
+
const max = sc.scrollHeight - sc.clientHeight, p = max > 0 ? sc.scrollTop / max : 0;
|
|
34
|
+
const travel = Math.max(0, row.scrollWidth - frame.clientWidth);
|
|
35
|
+
row.style.transform = `translateX(${-p * travel}px)`;
|
|
36
|
+
raf = requestAnimationFrame(tick);
|
|
37
|
+
}
|
|
38
|
+
raf = requestAnimationFrame(tick);
|
|
39
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Scrub reveal: every word's opacity is tied to scroll progress, offset by its place in the paragraph.
|
|
2
|
+
const PARAMS = { spread: 0.35 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const text = 'Scroll is the one input every visitor already knows. Tie the reveal to it and the reader sets the pace, word by word.';
|
|
6
|
+
const root = document.createElement('div');
|
|
7
|
+
root.className = 'sr-root';
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.sr-root { position: absolute; inset: 0; background: #0f2416; }
|
|
10
|
+
.sr-scroll { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
|
|
11
|
+
.sr-scroll::-webkit-scrollbar { display: none; }
|
|
12
|
+
.sr-sticky { position: sticky; top: 0; display: grid; align-content: center; padding: 0 9%; box-sizing: border-box; }
|
|
13
|
+
.sr-p { margin: 0; font: 700 clamp(14px, 6.4vmin, 44px)/1.18 "Mona Sans", system-ui, sans-serif; letter-spacing: -0.015em; color: #d3f9d8; }
|
|
14
|
+
.sr-p span { opacity: .15; }
|
|
15
|
+
</style><div class="sr-scroll" data-scroll><div class="sr-track"><div class="sr-sticky"><p class="sr-p"></p></div></div></div>`;
|
|
16
|
+
stage.append(root);
|
|
17
|
+
const sc = root.querySelector('.sr-scroll'), sticky = root.querySelector('.sr-sticky'), p = root.querySelector('.sr-p');
|
|
18
|
+
const H = stage.clientHeight;
|
|
19
|
+
sticky.style.height = H + 'px';
|
|
20
|
+
root.querySelector('.sr-track').style.height = H * 3.2 + 'px';
|
|
21
|
+
const words = text.split(' ').map((w, i, a) => { const s = document.createElement('span'); s.textContent = w + (i < a.length - 1 ? ' ' : ''); p.append(s); return s; });
|
|
22
|
+
let raf;
|
|
23
|
+
function frame() {
|
|
24
|
+
const max = sc.scrollHeight - sc.clientHeight, prog = max > 0 ? sc.scrollTop / max : 0;
|
|
25
|
+
const n = words.length, sp = PARAMS.spread;
|
|
26
|
+
words.forEach((w, i) => {
|
|
27
|
+
const k = Math.min(1, Math.max(0, (prog * (1 + sp) - i / n) / sp));
|
|
28
|
+
w.style.opacity = (0.15 + 0.85 * k).toFixed(3);
|
|
29
|
+
});
|
|
30
|
+
raf = requestAnimationFrame(frame);
|
|
31
|
+
}
|
|
32
|
+
raf = requestAnimationFrame(frame);
|
|
33
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Velocity-skew marquee: rows always drift; smoothed scroll velocity speeds them up and leans the type.
|
|
2
|
+
const PARAMS = { skew: 0.25, boost: 6 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const rows = ['Motion library · ', 'Sixty effects · ', 'Copy the code · '];
|
|
6
|
+
const root = document.createElement('div');
|
|
7
|
+
root.className = 'vs-root';
|
|
8
|
+
root.innerHTML = `<style>
|
|
9
|
+
.vs-root { position: absolute; inset: 0; background: #2f9e44; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
10
|
+
.vs-scroll { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
|
|
11
|
+
.vs-scroll::-webkit-scrollbar { display: none; }
|
|
12
|
+
.vs-stick { position: sticky; top: 0; display: grid; align-content: center; gap: 1vmin; overflow: hidden; }
|
|
13
|
+
.vs-row { white-space: nowrap; font-size: 15vmin; font-weight: 850; letter-spacing: -0.03em; line-height: 1.05; color: #ebfbee; will-change: transform; }
|
|
14
|
+
.vs-row:nth-child(2) { color: #15171a; }
|
|
15
|
+
</style><div class="vs-scroll" data-scroll><div class="vs-track"><div class="vs-stick"></div></div></div>`;
|
|
16
|
+
stage.append(root);
|
|
17
|
+
const sc = root.querySelector('.vs-scroll'), stick = root.querySelector('.vs-stick');
|
|
18
|
+
const H = stage.clientHeight;
|
|
19
|
+
stick.style.height = H + 'px';
|
|
20
|
+
root.querySelector('.vs-track').style.height = H * 4 + 'px';
|
|
21
|
+
const els = rows.map(t => { const el = document.createElement('div'); el.className = 'vs-row'; el.textContent = t.repeat(8); stick.append(el); return { el, x: 0 }; });
|
|
22
|
+
let lastTop = sc.scrollTop, vel = 0, last = performance.now(), raf;
|
|
23
|
+
function frame(now) {
|
|
24
|
+
const dt = Math.max(1, now - last); last = now;
|
|
25
|
+
const raw = (sc.scrollTop - lastTop) / dt; lastTop = sc.scrollTop; // px per ms
|
|
26
|
+
vel += (raw - vel) * 0.12; // smooth it
|
|
27
|
+
const speed = 0.04 + Math.abs(vel) * PARAMS.boost * 0.1;
|
|
28
|
+
const skew = Math.max(-20, Math.min(20, vel * PARAMS.skew * 40));
|
|
29
|
+
els.forEach((r, i) => {
|
|
30
|
+
const dir = i % 2 ? 1 : -1, w = r.el.scrollWidth / 8;
|
|
31
|
+
r.x = (r.x + dir * speed * dt) % w;
|
|
32
|
+
r.el.style.transform = `translateX(${r.x - (dir > 0 ? w : 0)}px) skewX(${-skew * dir}deg)`;
|
|
33
|
+
});
|
|
34
|
+
raf = requestAnimationFrame(frame);
|
|
35
|
+
}
|
|
36
|
+
raf = requestAnimationFrame(frame);
|
|
37
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Zoom-through: the word scales exponentially with scroll, around the hole of its O, until you fly through it.
|
|
2
|
+
const PARAMS = { maxScale: 28 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'zt-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.zt-root { position: absolute; inset: 0; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.zt-scroll { position: absolute; inset: 0; overflow-y: auto; scrollbar-width: none; }
|
|
10
|
+
.zt-scroll::-webkit-scrollbar { display: none; }
|
|
11
|
+
.zt-scene { position: sticky; top: 0; overflow: hidden; background: #ebfbee; display: grid; place-items: center; }
|
|
12
|
+
.zt-next { position: absolute; inset: 0; display: grid; place-items: center; font-size: 13vmin; font-weight: 800; color: #ebfbee; letter-spacing: -0.03em; background: #2f9e44; }
|
|
13
|
+
.zt-word { position: relative; font-size: 26vmin; font-weight: 900; font-stretch: 112%; color: #1b4d2b; letter-spacing: -0.04em; line-height: 1; will-change: transform; }
|
|
14
|
+
</style><div class="zt-scroll" data-scroll><div class="zt-track"><div class="zt-scene"><div class="zt-next">Inside.</div><div class="zt-word">GO<span class="zt-o">O</span></div></div></div></div>`;
|
|
15
|
+
stage.append(root);
|
|
16
|
+
const sc = root.querySelector('.zt-scroll'), scene = root.querySelector('.zt-scene'), word = root.querySelector('.zt-word'), o = root.querySelector('.zt-o'), next = root.querySelector('.zt-next');
|
|
17
|
+
const H = stage.clientHeight;
|
|
18
|
+
scene.style.height = H + 'px';
|
|
19
|
+
root.querySelector('.zt-track').style.height = H * 3.5 + 'px';
|
|
20
|
+
// The scene background paints over .zt-next until the zoom passes through the O's counter.
|
|
21
|
+
scene.style.background = 'transparent';
|
|
22
|
+
word.style.background = 'transparent';
|
|
23
|
+
const cover = document.createElement('div'); cover.style.cssText = 'position:absolute;inset:0;background:#ebfbee;will-change:opacity';
|
|
24
|
+
scene.insertBefore(cover, word);
|
|
25
|
+
let raf;
|
|
26
|
+
function frame() {
|
|
27
|
+
const max = sc.scrollHeight - sc.clientHeight, p = max > 0 ? sc.scrollTop / max : 0;
|
|
28
|
+
// origin = centre of the O's counter, relative to the word box
|
|
29
|
+
const ox = o.offsetLeft + o.offsetWidth / 2, oy = o.offsetTop + o.offsetHeight * 0.52;
|
|
30
|
+
word.style.transformOrigin = `${ox}px ${oy}px`;
|
|
31
|
+
const s = Math.pow(PARAMS.maxScale, Math.min(1, p / 0.8));
|
|
32
|
+
word.style.transform = `scale(${s})`;
|
|
33
|
+
const through = Math.min(1, Math.max(0, (p - 0.55) / 0.25));
|
|
34
|
+
cover.style.opacity = String(1 - through);
|
|
35
|
+
word.style.opacity = String(1 - Math.max(0, (p - 0.72) / 0.1));
|
|
36
|
+
next.style.transform = `scale(${1.25 - 0.25 * through})`;
|
|
37
|
+
raf = requestAnimationFrame(frame);
|
|
38
|
+
}
|
|
39
|
+
raf = requestAnimationFrame(frame);
|
|
40
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Aurora gradient: domain warping. The noise coordinates are pushed around by another noise,
|
|
2
|
+
// which folds the field into slow curtains; the result picks colours from a short palette.
|
|
3
|
+
const PARAMS = { speed: 0.3, warp: 1.2 };
|
|
4
|
+
|
|
5
|
+
export default function (stage) {
|
|
6
|
+
const canvas = document.createElement('canvas');
|
|
7
|
+
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
|
|
8
|
+
stage.append(canvas);
|
|
9
|
+
const gl = canvas.getContext('webgl2', { antialias: false });
|
|
10
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
11
|
+
const vs = `#version 300 es
|
|
12
|
+
in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
|
|
13
|
+
const fs = `#version 300 es
|
|
14
|
+
precision highp float; in vec2 uv; out vec4 o;
|
|
15
|
+
uniform vec2 R; uniform float T, Wp;
|
|
16
|
+
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
17
|
+
float noise(vec2 p) { vec2 i = floor(p), f = fract(p), u = f * f * (3. - 2. * f);
|
|
18
|
+
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); }
|
|
19
|
+
float fbm(vec2 p) { float s = 0., a = .5; for (int i = 0; i < 5; i++) { s += a * noise(p); p = p * 2.03 + 11.7; a *= .5; } return s; }
|
|
20
|
+
void main() {
|
|
21
|
+
vec2 p = uv * vec2(R.x / R.y, 1.) * 2.2;
|
|
22
|
+
vec2 q = vec2(fbm(p + vec2(0, T * .6)), fbm(p + vec2(5.2, 1.3) - T * .4));
|
|
23
|
+
vec2 r = vec2(fbm(p + Wp * 2.5 * q + vec2(1.7, 9.2) + T * .3), fbm(p + Wp * 2.5 * q + vec2(8.3, 2.8)));
|
|
24
|
+
float f = fbm(p + Wp * 2. * r);
|
|
25
|
+
vec3 night = vec3(.02, .05, .1), green = vec3(.2, .95, .6), teal = vec3(.08, .55, .6), rose = vec3(.95, .45, .6);
|
|
26
|
+
vec3 col = mix(night, teal, smoothstep(.2, .6, f));
|
|
27
|
+
col = mix(col, green, smoothstep(.45, .85, length(q) * f));
|
|
28
|
+
col = mix(col, rose, smoothstep(.62, .95, r.y * f * 1.4) * .8);
|
|
29
|
+
col *= .55 + .7 * smoothstep(0., 1., uv.y); // brighter toward the top of the sky
|
|
30
|
+
o = vec4(col, 1);
|
|
31
|
+
}`;
|
|
32
|
+
const sh = (t, s) => { const x = gl.createShader(t); gl.shaderSource(x, s); gl.compileShader(x); if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)); return x; };
|
|
33
|
+
const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(prog, 0, 'p'); gl.linkProgram(prog); gl.useProgram(prog);
|
|
34
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
35
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
36
|
+
const uR = gl.getUniformLocation(prog, 'R'), uT = gl.getUniformLocation(prog, 'T'), uW = gl.getUniformLocation(prog, 'Wp');
|
|
37
|
+
const resize = () => { const d = Math.min(devicePixelRatio, 1.5); canvas.width = stage.clientWidth * d | 0; canvas.height = stage.clientHeight * d | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
|
|
38
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
39
|
+
let raf;
|
|
40
|
+
function frame(now) {
|
|
41
|
+
gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform1f(uT, now / 1000 * PARAMS.speed); gl.uniform1f(uW, PARAMS.warp);
|
|
42
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
43
|
+
raf = requestAnimationFrame(frame);
|
|
44
|
+
}
|
|
45
|
+
raf = requestAnimationFrame(frame);
|
|
46
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
|
|
47
|
+
}
|