Nakodo's landing page has four drawings that move on a loop, a bird that pecks at a plate every seven seconds, and fourteen bars that grow from nothing. No JavaScript animates any of it. It is all keyframes in one stylesheet, driven by two tiny client components whose entire job is to set an attribute.
globals.css is 865 lines and holds 24 @keyframes blocks. This is what the discipline in that file comes down to.
Paused is the default, not a class you add
The naive version of "animate while visible" adds and removes a class. That means the animation restarts from frame zero every time, which for a loop is wrong: a bird mid hop snapping back to standing is worse than no animation.
So every looping animation is declared with paused baked into the shorthand, and nothing ever adds or removes it:
/* Durations differ, so the four drawings never fall into step. */
:is(.illo-play, .illo-loop) .illo-river-thumb {
animation: illo-river 10s linear calc(var(--at) * -10s) infinite paused;
}
:is(.illo-play, .illo-loop) .illo-pencil {
animation: illo-pencil 4.5s ease-in-out infinite paused;
}
:is(.illo-play, .illo-loop) .illo-hop {
animation: illo-hop 3.2s 0.6s infinite paused;
}
One selector is the play button:
:is(.illo-play[data-play]:not(:hover), .illo-loop)
:is(.illo-river-thumb, .illo-current, .illo-pencil, .illo-ink, .illo-hop, .illo-alcy-hop) {
animation-play-state: running;
}
Pausing and resuming an animation keeps its position, so a drawing you scroll past and come back to carries on from where it was. Hovering a step pauses it, which falls out of :not(:hover) for free and turns out to be the feature people use: the loops are short, and stopping one is how you look at it.
The React side is 20 lines and sets one attribute:
export function IlloPlay({ className, children }) {
const ref = useRef<HTMLLIElement>(null);
const [onScreen, setOnScreen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<li ref={ref} data-play={onScreen ? "" : undefined} className={cn("illo-play", className)}>
{children}
</li>
);
}
No animation state in React at all. The component does not know what moves inside it or how long it takes. In the app's empty states the drawing carries .illo-loop instead and always runs, which is the same CSS with no component.
A negative delay is a phase offset
There are ten thumbnails drifting down the river illustration, and they must not all be at the same point in the cycle. The usual fix is ten animations, or a positive delay that leaves everything stacked at the start for the first few seconds.
A negative delay solves it properly. The animation begins mid cycle:
animation: illo-river 10s linear calc(var(--at) * -10s) infinite paused;
--at is a number from 0 to 1 set per element in the JSX, and the comment in the keyframes explains why it does double duty:
/* river: thumbnails drift along the centre of the channel, entering on the
left and leaving on the right. Each thumbnail's --at (0 to 1) sets how far
along it starts, which is also where it rests. */
Where it rests matters because of reduced motion and because of pause. An element whose animation never runs renders at the keyframe its delay puts it on, so one number controls both the motion and the still picture. If --at only offset the timing, the paused state would be ten thumbnails in a heap.
The durations are deliberately coprime-ish rather than round: 10s, 4s, 4.5s, 3.2s, 3.8s. Four drawings in a row on the same duration read as one machine.
Easing belongs inside the keyframes
A hop is not one easing curve. Going up decelerates, coming down accelerates, and landing squashes. Expressing that with a single animation-timing-function is impossible, so the timing function is set per keyframe, which is legal CSS and rarely used:
@keyframes illo-hop {
0% {
transform: translate(0, 0) rotate(0deg) scale(1, 1);
animation-timing-function: cubic-bezier(0.3, 0, 0.5, 1);
}
9% {
transform: translate(-1px, -5px) rotate(-4deg) scale(1, 1);
animation-timing-function: cubic-bezier(0.5, 0, 0.7, 1);
}
18% {
transform: translate(0, 0) rotate(0deg) scale(1.04, 0.95);
animation-timing-function: ease-out;
}
...
}
Each stop declares the curve used to leave it. Three segments, three curves, and the bird falls rather than floats.
The other rule in that file is in a comment above the loops:
/* Every keyframe spells out the same transform list, so the browser
interpolates each part instead of falling back to matrices. */
Two keyframes with translate() rotate() scale() in the same order interpolate component by component. Change the list between stops, write rotate() in one and translate() rotate() in the next, and the browser decomposes both to matrices and interpolates those instead. The animation still plays. It just takes a slightly different path, and a rotation plus a scale through matrix interpolation is how you get a drawing that shears for a few frames.
pathLength makes a stroke a unit, not a measurement
Two of the illustrations animate strokes, and both set pathLength so the CSS never mentions a real length.
The notebook's written line uses pathLength={1}, so a dash offset of 1 is the whole line and 0 is none of it, whatever the path's actual geometry:
@keyframes illo-ink {
0%, 13.9% { opacity: 0; stroke-dashoffset: 1; }
14% { opacity: 1; stroke-dashoffset: 1; }
52%, 76% { opacity: 1; stroke-dashoffset: 0; }
88% { opacity: 0; stroke-dashoffset: 0; }
89%, 100% { opacity: 0; stroke-dashoffset: 1; }
}
The river's current uses pathLength={100}, and the dashes move exactly one dash period per cycle, so the loop is seamless with no end to hide:
@keyframes illo-current {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -50; }
}
Edit the path in the SVG and neither animation changes. That is the point: without pathLength these numbers would be measurements of a specific curve, and the first redraw would break them.
Tally marks on the app's counters are drawn the same way, with one detail that took two attempts to get right:
/* A gap longer than the dash, so no round cap shows at the far end while
a stroke waits to be drawn. */
.draw-in {
stroke-dasharray: 1 2;
}
With stroke-linecap: round and a dash array of 1 1, the next dash's rounded cap pokes out at the far end of a stroke that has not been drawn yet. A tiny grey dot, on every mark, waiting. Making the gap twice the dash pushes it out of the viewBox.
The chart that is already on screen must not move
The last component is the one with the opinion in it. Bars grow when a chart scrolls into view. The question is what happens to a chart that is already in view when the page loads.
Both answers are defensible, and we ship both, because they are different situations:
// By default a chart already on screen when the page loads is left alone, so
// nothing collapses in front of the reader (the landing). With `enter`, used
// in the app, it grows as the page opens instead: the server renders it
// already animating, so the bars start from nothing on the first paint, and
// only a chart below the fold is held back until it is scrolled to.
The marketing page gets the default, and the case it protects is the one you hit on a tall screen or by following an anchor link straight to the section: the charts are already in front of you when the effect runs. Animating then means the reader watches a chart collapse to zero and come back. That is not a reveal, it is a glitch.
The app gets enter, and the mechanism is the interesting half: data-reveal="go" is in the server rendered HTML, so the bars are mid animation on the first paint and never render at full width at all. No effect has to run first, and there is no flash.
The effect only ever does one thing, which is hold back what is below the fold:
useEffect(() => {
const el = ref.current;
if (!el) return;
const box = el.getBoundingClientRect();
if (box.top < window.innerHeight && box.bottom > 0) return; // already visible: leave it
el.dataset.reveal = "wait";
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
el.dataset.reveal = "go";
}, { threshold: 0.5 });
observer.observe(el);
return () => observer.disconnect();
}, []);
Setting "wait" in an effect rather than in the markup is the whole trick. The server cannot know what is above the fold, so it renders the honest state, and the first client effect either leaves it alone or hides it before the browser has painted anything a reader would notice. Staggering is calc(var(--i, 0) * 70ms) with --i as an inline custom property, so fourteen bars need no JavaScript to queue up.
Reduced motion twice, on purpose
Nine of the media queries in the file open with prefers-reduced-motion: no-preference. Our own animations are not overridden for a reader who asked for less motion; they are never declared:
@media (prefers-reduced-motion: no-preference) {
[data-reveal="go"] .grow-x {
animation: grow-x 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i, 0) * 70ms) both;
}
}
With no animation, a bar is simply its final width, because the keyframes only have a from. The to is the element's real style. That is why grow-x is four lines:
@keyframes grow-x {
from { transform: scaleX(0); }
}
There is also a blunt instrument at the bottom of the file that kills every animation and transition when reduced motion is requested. That one is not for our drawings. It is for everything we did not write: component library transitions, utility classes, anything a dependency animates. Gating our own code on no-preference and sweeping up after everything else is the combination that actually holds, because only one of those two can be audited by reading the stylesheet.
Where this stops
None of this is a general animation system, and that is the trade. There is no timeline, no orchestration, no spring. A sequence longer than a loop, or one that has to respond to a drag, would be a bad fit. What it does buy is that the motion in the product is readable in one file, costs no JavaScript on the critical path, and keeps working when the bundle fails. If you want to see the whole thing, the landing page runs it all, and the method page is the version of the same story written for someone who wants numbers rather than birds.
Top comments (1)
Have you checked whether pathLength and rounded caps still render consistently when those drawings scale down?