Nakodo's landing page has a plate of sample creator entries on the right and the headline on the left. Since Friday it also has weather: every 18 seconds a gust carries a few dozen autumn leaves in from the left, whirls them clockwise round the plate, and lets them go over the top so they fly off the right edge.
You can watch it on the Nakodo home page right now. The whole thing is one component, src/components/illo/leaf-gust.tsx, 318 lines, and there is not a single CSS keyframe in it.
Why not keyframes
The obvious implementation is a handful of CSS animations with offset-path and a few delays. I tried it and it was wrong in a way that is hard to fix: the path of a leaf has to depend on where the plate is, and the plate moves. It is beside the copy on a desktop, below it on a phone, and it changes height whenever the font loads or the viewport resizes. A keyframe cannot be told "go round whatever that element currently is".
So the component measures:
const anchor = svg.parentElement?.querySelector("[data-gust-around]");
The plate carries data-gust-around, the overlay sits on top of it with position: absolute; inset: 0, and a ResizeObserver watching both the overlay and the plate re-measures the whirl whenever either one changes. The animation is the same code on every screen; only the numbers it measures differ.
The whirl is a superellipse
The first version went round an ellipse and looked wrong. The plate is a rounded rectangle, and an ellipse drawn outside a rectangle cuts the corners: the leaves kept drifting diagonally away from the plate and then diving back in.
What I wanted was a rounded rectangle, which is what a superellipse is:
const SQUARENESS = 4; // 2 is an ellipse; higher hugs the plate's corners
With half-widths a and b, a point is on the path when
const rho = (ax ** SQUARENESS + ay ** SQUARENESS) ** (1 / SQUARENESS);
equals 1, where ax and ay are the distances from the centre divided by a and b. With SQUARENESS at 2 that is the equation of an ellipse. At 4 it is a squircle that hugs the plate's sides and turns sharply at the corners, which is exactly the shape of the thing the wind is going round.
Being an implicit curve rather than a parametric one turns out to be the useful part. I never need a point on the path; I need to know, for a leaf that is wherever it is, which way is "out" and which way is "along". Out is the gradient of that expression:
let nx = (Math.sign(qx) * ax ** (SQUARENESS - 1)) / a;
let ny = (Math.sign(qy) * ay ** (SQUARENESS - 1)) / box.b;
normalised, and "along, clockwise" is that vector rotated a quarter turn: (-ny, nx). Two lines and no parametrisation, no arc length, no lookup table.
Each leaf also gets its own orbit, drawn from ORBITS = [0.8, 1.12], where 1 is the path itself. The air pushes it towards its own orbit rather than towards the curve, so the stream has some thickness and the inner leaves pass over the plate's edge while the outer ones stay clear of it.
Every leaf is a particle, and the air has a velocity
Nothing follows a path directly. Each leaf has a position and a velocity, and on every frame it works out the velocity of the air where it is, then eases towards it:
const k = Math.min(1, dt / lag);
l.vx += (ux + fx - l.vx) * k;
l.vy += (uy + fy - l.vy) * k + (1 - held) * (1 - 0.85 * gust) * (FALL / lag) * dt;
lag is between 0.2 and 0.4 seconds per leaf, which is the whole reason it reads as a leaf and not as a dot on a track: it arrives at the corner late, overshoots slightly, and gets pulled back. fx and fy are a flutter, two sine waves at 2.1 and 2.9 radians per second with a per-leaf phase, so no two leaves wobble together.
held is how strongly the whirl has hold of a leaf, and it is the term the whole effect hangs on:
const held = whirl * grip * (1 - ease((rho - 1.7) / 0.8));
Well inside the whirl, held is 1 and the leaf goes round. Outside rho of about 2.5 it is 0 and the leaf is just in open wind. In between it fades, which is how leaves join the whirl on arrival without anything deciding that they have joined it.
Gravity is multiplied by (1 - held): a leaf in the whirl does not fall, because the whirl is what is holding it up. A leaf out of it does. That one factor replaced about thirty lines of state machine from my first attempt, which had an explicit "orbiting" flag and a transition into and out of it.
Letting go
After RELEASE seconds, each leaf is allowed to leave, but only at the right moment:
if (l.freedAt === Infinity && since > l.ready && qy < -0.5 && qx > 0.2) l.freedAt = t;
That is: over the top of the plate, past the centre, heading right. Leaves let go one at a time, each on its own next pass, because ready is RELEASE plus a random part of a loop. Then grip fades over 0.4 seconds, the gust lifts whatever it has freed, and they drift up and off the right edge. Nothing is ever deleted mid-screen: a leaf goes alive = false only once it is 60px past the right or bottom edge, or a full box height above the top.
The timeline is six constants, in seconds:
const GUST_EVERY = 18;
const ARRIVE = 2.2; // the leaves set off one after another over this long
const LOOP = 3.6; // one time round the whirl
const RELEASE = 6.5; // from now, each leaf lets go on a pass over the top
LOOP being a constant means the speed is not a constant. A superellipse this square is about 3.5 times its half-width plus its half-height around, so:
box.whirl = (3.5 * ((box.left + box.right) / 2 + box.b)) / LOOP;
A big screen has a big plate and fast leaves, a small screen has slow ones, and a loop takes 3.6 seconds on both. Speed falls out of size, which is the only way I found to make an animation like this feel the same on a 13 inch laptop and a 27 inch monitor.
Turning over
A leaf is two paths, the blade and the rib, taken from the leaves on the bird's branch in our illustration set. They used to be inline in that one drawing; shipping this meant pulling them out into an exported constant so both files use the same shape:
export const LEAF = {
blade: "M0 0 C-0.5 -3.7 1.9 -6.9 5.8 -6.9 C6.5 -3.1 3.8 -0.1 0 0 Z",
rib: "M0 0 L4.2 -5",
};
Each leaf is drawn once, centred on its middle and pointing along x, and then every frame gets one transform:
`translate(${l.x} ${l.y}) rotate(${deg}) scale(${along} ${across})`
across is size * Math.cos(l.flip), so it passes through zero: the leaf narrows to a line and comes back the other way, which is what a leaf tumbling in air does. along wobbles by 20% on a different period so it is not a pure cylinder.
That is also why both paths carry vectorEffect="non-scaling-stroke". The scale factor goes to zero several times a second, and without it the rib's stroke would vanish and reappear along with it.
Depth is a golden ratio sequence rather than a random number:
const depth = (i) => (i * 0.618034) % 1;
so neighbouring leaves in the DOM are never the same size, and nothing clumps the way uniform random sampling does. Size, spin response and opacity all come off it.
The cheap parts
Four things keep it from being a battery complaint:
-
No leaves at all under reduced motion. The effect returns before it creates anything if
prefers-reduced-motion: reducematches, and the SVG renders 45 hidden groups and nothing else. -
No frames while off screen. An
IntersectionObserversetsonScreen, and therequestAnimationFrameloop stops scheduling itself when it is false. Scrolling down the page stops the simulation; scrolling back restarts it withlast = 0so the first frame has a zerodtinstead of a 40 second one. -
A clamped delta.
Math.min((now - last) / 1000, 1 / 20)means the worst case of a long frame is 50ms of simulated time, so nothing teleports. - Fewer leaves on smaller screens. 45 on a desktop, 34 under 1024px, 22 under 640px, at 0.85 scale. The groups are all in the DOM either way; the extra ones are never launched.
Per frame the work is one object update and one setAttribute per live leaf. No React state, no re-render: the component renders once and the effect writes transforms straight to the elements it kept refs to.
The overlay also carries a mask so leaves fade out in the top and bottom 3rem instead of being clipped by the section edge:
[mask-image:linear-gradient(to_bottom,transparent,black_3rem,black_calc(100%-3rem),transparent)]
and pointer-events-none, because an invisible 45-element SVG lying over your call to action is a very annoying bug to ship.
Was it worth 318 lines
The honest answer is that a decorative animation is never the most valuable thing you can build in an afternoon. What made it worth doing was that the plate it goes round is not a stock illustration: it is a real sample of what the app produces, the same entry layout a customer sees, which is the thing how Nakodo works spends eight sections describing. The animation exists to make you look at it.
The part I would keep in any future version of this is the implicit curve. If you ever need things to move around a UI element rather than along a designed path, do not reach for offset-path: write the shape as an inequality, take its gradient for "away", rotate that for "along", and let a lag term do the rest. The result is one block of maths that does not care where the element is, how big it got, or which breakpoint you are on.
Top comments (0)