18 Organic motion 18 Organic motion 18 Organic motion 18 Organic motion
Nature is random, but never jumpy: each moment stays close to the one before it.
A candle flame never holds still, and it never jumps. Neither does a leaf in a breeze or a field of tall grass. They move all the time, in no pattern you could predict, and yet each moment is close to the one before it. Nothing in nature teleports.
Chapter 17’s particles used Math.random() once, at birth, to pick each direction, and that is where it belongs. When code asks it for a new value on every frame, it gets the wrong kind of unpredictable. A fresh random number has no memory of the last one, so the motion flickers. Nature’s randomness is smooth: unpredictable over time, continuous from one frame to the next. Graphics programmers call it noise.
Random against noise
Two dots wander inside the same space. The left one gets a new random position on every frame. The right one reads its position from noise. The blue line behind each dot is where it has just been.
The left dot is a buzz. It covers its whole box almost at once, and you can’t follow it, because on every frame it lands somewhere that has nothing to do with where it was. Its trail is a scribble. The right dot drifts. It wanders, turns, speeds up and slows down, and you couldn’t say where it will be later; but from one frame to the next it barely moves, so its trail is a smooth line. That’s the whole idea: unpredictable in the long run, continuous in the short run.
Which one is alive?
Three shapes, doing nothing. This is how each one waits. Watch them for a few breaths, then ask two questions: which one looks alive, and which one looks like a machine?
Decide before you read on.
A loop the eye can learn
The machine is on the left. It breathes with a single sine wave, scale = 1 + 0.04 · sin(2t): 4% bigger, then 4% smaller, once every 3.1s. Each breath is smooth. The trouble is the second breath, which is exactly the same as the first, and the third. By the third you can predict the fourth, and a thing you can predict stops looking alive. It looks like a pump.
The other two never settle into a rhythm you can learn. The middle one is built from sines too, but several of them, with lengths that don’t fit together. Its breath is two sines, one every 3.7s and a smaller one every 2.2s, plus a sway every 7.0s and a tilt every 4.8s. None of those is a simple multiple of another, so they keep meeting in new combinations, and the whole pattern comes back into step only once every 62.8s.
Two loops are back in step when both have just finished a whole number of runs, and how soon that happens depends on how their lengths relate:
| Two loops | Back in step after |
|---|---|
| 2s and 4s | 4s |
| 3s and 4s | 12s |
| 3.7s and 5.3s | 196.1s |
Lengths with a simple ratio lock together quickly, and the eye finds the pattern. So pick lengths with no simple ratio. One easy way: two prime numbers of tenths of a second, like 37 and 53. They share no factor, so the pair only repeats after their product: 1961 tenths, the 196.1s in the table.
The blob on the right has no loop to find. It is driven by noise.
Random has no memory
Look at figure 18.1 again. The left dot’s code is one line per axis, run on every frame: x = center + range · random(−1, 1). At 60 frames a second, that is 60 unrelated positions a second, each as likely to land at the far edge as right next door. In chapter 3’s terms, the gap between two frames can be as wide as the whole range, so the eye loses the object on every frame and sees a flicker instead of a motion.
Nothing in nature can move like that. A leaf has mass, so a sixtieth of a second later it can’t be far from where it was, and the wind pushing it changes gradually too. A flame’s shape now is made of the air that was rising a moment ago. The technical word is correlated: each moment is correlated with the one before it. That is the one property noise is built to have. Nearby inputs give nearby outputs.
The right dot’s code is the same line with a different source: x = center + range · noise(t · speed). Same center, same range. Only the randomness changed, from values that forget to values that remember.
How noise works
Perlin’s noise is simple in one dimension, and one dimension is all you need for a single wobbling number. Take the number line. At every whole number, pin the curve to zero and give it a random slope there: uphill or downhill, steep or gentle. Between two whole numbers, blend from the line leaving the left pin to the line arriving at the right one, along a smooth S-curve. The result is a curve of random hills and valleys, with no corners anywhere.
The S-curve is 6t⁵ − 15t⁴ + 10t³, from Perlin’s 2002 paper Improving Noise. Its first and second derivatives are zero at both ends, so a motion driven by it has no jumps in speed or in acceleration. The JavaScript at the end of this chapter is exactly this, in a few lines. One worked point: halfway between two pins, at x = 3.5, the left line has climbed half its slope and the right one is half a slope below zero, and the blend takes the middle, so the value there is a quarter of the difference of the two slopes. Doubling it at the end brings the extremes to 1.
The whole numbers set the grain. Across a hundredth of a unit, noise barely changes. Across one unit it can swing through most of its range, and two units apart the values have nothing in common, because they are built from different slopes. It always stays between −1 and 1.
Figure 18.1 uses the later simplex noise, in two dimensions, and reads x and y along two separate lines of it. You don’t need it: the short one-dimensional version in the code below is enough for idle motion.
Walking through noise
To drive a motion with noise, walk along it. Take noise(t · speed), with t in seconds, multiply it by an amplitude, and add it to the resting value. That gives you two dials, and they are timing and spacing again.
- Speed is how many units of noise pass each second. At
0.3the value turns around every few seconds: a drift. At3it turns several times a second: a tremble. - Amplitude is how far it goes. Noise stays between −1 and 1, so the amplitude is the largest move you will ever get.
The noise dot starts at a speed of 0.6. Drag Noise speed down to 0.1 and it barely wanders. Drag it up to 3 and it gets busy and nervous, but its trail is still a line, not a scribble. Faster noise is still continuous. It never turns into random.
Each property needs its own stretch of noise. Read x from noise(t · s) and y from noise(t · s) as well, and they are the same number: the dot slides back and forth along one straight line. Offset one of them, noise(t · s + 100), and the two are unrelated, because they are read from parts of the line far apart.
One layer of noise is smooth hills. Real things have detail on top: a flame leans slowly and flickers quickly at the same time. So add noise to itself, each new layer twice as fast and half as strong. Each layer is an octave, and the sum is called fractal noise, or fBm (fractional Brownian motion). For idle motion, one or two octaves are plenty.
Idle: alive, not busy
Some things should look alive even while nothing is happening: a voice assistant that is listening, a character on an empty screen, a live indicator. A character who stops moving keeps breathing, shifting its weight, blinking. This is secondary motion. It supports the thing without becoming the thing.
Three rules keep it that way.
- Small. Idle motion should be almost unnoticed. In figure 18.2 the noise blob grows or shrinks by at most 4.5%, drifts at most 8px, and tilts at most a little over 3°. If people notice the motion before they notice the thing, it is too big.
- Slow. Breaths, not a pulse. Loops several seconds long, noise walked at less than one unit a second.
- Honest. Idle motion says this is alive, or this is on. Use it where that is true. A button that breathes on its own is asking for attention it hasn’t earned.
The cost of forever
An idle animation never ends, so its cost never ends either. Every frame it runs is work for the device, for as long as the page is open, spent on motion almost nobody is looking at. So:
- Keep it cheap. Animate
transformandopacity, or the individualtranslate,rotateandscale, never size or position. Chapter 20 shows why. - Pause it when no one can see it. Stop the loop when the element scrolls out of view, with an
IntersectionObserver, and while the tab is hidden, withdocument.hidden. Most browsers already stop callingrequestAnimationFramein hidden tabs; checking yourself lets the loop resume cleanly, without the one hugedtof chapter 14. - Stop it under reduced motion. Most motion should be reduced, not removed (chapter 21). Idle motion is the exception. It carries almost no information, so under
prefers-reduced-motion: reduceit simply stops. If it does carry a message, like listening, keep the message in a still form: a colour, a ring, a label.
Make it a machine, then bring it back
This is figure 18.2 with its code. Change one thing at a time.
- Turn Speed up to 3. All three blobs get nervous. Idle motion lives at the slow end. Put it back to 1.
- In the code, find the middle blob’s breath:
Math.sin(t * 1.7)andMath.sin(t * 2.9 + 1). Change2.9to3.4, exactly twice1.7. Now the breath repeats every 3.7s, and within a few breaths you can feel the loop. Change it back. - Find the noise blob’s breath,
0.045 * noise1(t * 0.6), and change0.045to0.15. It stops breathing and starts to wobble like jelly. Idle motion lives at the small end, too.
The notation
CSS has no noise. It has something nearly as good for idling: several loops at once, on separate properties, with lengths that don’t line up. Two details make it work. The loops go on the individual scale and rotate properties, because two animations on transform don’t add up: the later one in the list replaces the earlier one. And each loop runs alternate, there and back, so a 3.7s loop takes 7.4s to come home.
In JavaScript you can have real noise. The second snippet carries its own, the gradient noise described above, and drives a small wobble from a requestAnimationFrame loop with dt, so it moves at the same speed at any refresh rate. It pauses whenever the element is out of view or the tab is hidden, and it never runs under reduced motion. sync() is the one place that decides: the loop runs only if all three conditions allow it, and it is called whenever any of them changes. The amounts (3px, 2 degrees, 3%) are deliberately small: try 30px, 20 degrees and 30% and see the same code turn from breathing into shaking. Try speeds of 2.9, 3.7 and 5.3 in place of 0.29, 0.37 and 0.53 and it trembles instead of drifting.
/* A breath and a sway whose lengths don't line up.
Each loop runs there and back (alternate), so they come home
every 7.4s and 10.6s, and repeat together only every 392.2s. */
.box {
animation:
breathe 3.7s ease-in-out infinite alternate,
sway 5.3s ease-in-out infinite alternate;
}
/* scale and rotate are separate properties, so the two loops
combine. Two animations on transform would not: the later wins. */
@keyframes breathe {
from { scale: 1; }
to { scale: 1.04; }
}
@keyframes sway {
from { rotate: -1.5deg; }
to { rotate: 1.5deg; }
}
/* Idle motion carries almost nothing: under reduced motion, stop it. */
@media (prefers-reduced-motion: reduce) {
.box { animation: none; }
} // A slow, uneven wobble driven by noise. It pauses when no one
// can see it, and never runs under reduced motion.
const el = document.querySelector('.box');
// 1D gradient noise: a random slope at every whole number, blended
// smoothly in between. Always within -1..1, and 0 at whole numbers.
// 256 slopes: it repeats every 256 units, minutes away at these speeds.
const slopes = Array.from({ length: 256 }, () => Math.random() * 2 - 1);
const fade = (f) => f * f * f * (f * (f * 6 - 15) + 10);
function noise(x) {
const i = Math.floor(x);
const f = x - i;
const a = slopes[i & 255] * f; // the line leaving the left pin (i & 255 wraps i into 0..255)
const b = slopes[(i + 1) & 255] * (f - 1); // the line arriving at the right pin
return (a + (b - a) * fade(f)) * 2; // blend a to b, then scale up to fill -1..1
}
let t = 0; // seconds of wobble so far
let last = null; // time of the previous frame
let raf = 0;
let onScreen = true;
function frame(now) {
// dt in seconds, clamped so a stalled frame can't make it jump
const dt = last === null ? 0 : Math.min((now - last) / 1000, 1 / 15);
last = now;
t += dt;
// Each property walks its own stretch of noise, at speeds with no
// common factor, so the three rarely pass through rest together.
// At t = 0 all three read a whole number, so it starts at rest.
const y = 3 * noise(t * 0.29); // drift, up to 3px
const r = 2 * noise(t * 0.37 + 100); // tilt, up to 2deg
const s = 1 + 0.03 * noise(t * 0.53 + 200); // breath, up to 3%
el.style.transform = `translateY(${y}px) rotate(${r}deg) scale(${s})`;
raf = requestAnimationFrame(frame);
}
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function sync() {
const run = onScreen && !document.hidden && !reduce.matches;
if (run && !raf) {
last = null; // resume where it stopped, without one huge dt
raf = requestAnimationFrame(frame);
} else if (!run && raf) {
cancelAnimationFrame(raf);
raf = 0;
}
if (reduce.matches) el.style.transform = ''; // at rest
}
new IntersectionObserver((entries) => {
onScreen = entries.at(-1).isIntersecting;
sync();
}).observe(el);
document.addEventListener('visibilitychange', sync);
reduce.addEventListener('change', sync);
sync(); The noise clock t only moves while the loop runs. After a pause the wobble picks up exactly where it stopped, so coming back into view never makes it jump.
That closes the canvas chapters. Chapter 19 steps back and asks which medium should carry a motion at all: CSS, the Web Animations API, SVG, canvas or WebGL.
Blind A/B
Two versions of the same motion. Pick the one that feels right, then name why in one word. Judge by eye first, the way you judged the three blobs; the reason comes second.
Two versions. Which is better? Then say why, in one word.Taste: judging, then naming the reason.
5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.