Nakodo's illustrations are hand-written SVG React components: a woodpecker, a tree, shrubs, icons, all drawn on a shared unit grid and coloured by three CSS variables. There are 74 of them in the HTML of the landing page.
When we made a vertical reel for Instagram, the obvious temptation was to redraw the bird in the video project. I did not want two birds. A mascot that is slightly different in the advert than in the product is a bug you cannot fix later, because by then both versions are shipped.
So the video imports the components:
import { ALCY, AlcyBody, AlcyHead, HEAD_X, HEAD_Y } from "../../src/components/illo/alcy";
import { FILL, Hatch, LINE, SPOT, url } from "../../src/components/illo/illo";
reels/ is its own pnpm workspace root, deliberately, so that Remotion and its seven platform-specific compositor builds stay out of the app's lockfile. The relative imports reach up out of that workspace into the app's src. Which works for the TypeScript, and immediately breaks React: those files resolve react from the app's node_modules, the video resolves its own, and two Reacts in one tree means every hook throws.
The whole fix is a webpack alias in the Remotion config:
Config.overrideWebpackConfig((config) => ({
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
react: path.resolve("node_modules/react"),
"react-dom": path.resolve("node_modules/react-dom"),
},
},
}));
Every react import, from either side of the boundary, lands on the video's copy. This is the same trick a monorepo uses for a shared component library, except there is no monorepo here, just two package roots that happen to be nested.
Three variables, and a copy of the palette
The illustrations never name a colour. They stroke with var(--illo-line), fill with var(--illo-fill), and use var(--illo-spot) for the single accent mark. In the app that is what lets a drawing invert inside a dark panel without a second asset. In the video it means the entire colour setup is one style object:
export const illoOnPaper = {
"--illo-line": C.ink900,
"--illo-fill": C.paper0,
"--illo-spot": C.ochre400,
} as CSSProperties;
You can watch the mechanism work on the live site. Open nakodo.app and run:
document.documentElement.style.setProperty("--illo-line", "red");
Measured just now: 456 elements on that page have a stroke, and 306 of them turn red. The other 150 do not, and that is the interesting half: they sit inside .panel sections that redeclare the variable locally, so the inversion rule wins over the root. One line of CSS, two levels of override, no JavaScript involved in either.
C is a literal copy of the app's palette as oklch() values. The video has no Tailwind and no stylesheet pipeline, so there is nothing to import. A copied palette is a real risk of drift, which is why it is one object in one file with a comment pointing at globals.css, rather than colours scattered through six scenes.
The line width is a prop because the camera moves
The tree in the reel is the tree from the sign-in page, on the same 200 by 280 grid, with a fuller crown. The scene zooms in on it, and a zoom applied to an SVG scales the stroke with everything else: the bark lines thicken as the camera closes, which looks like a different drawing rather than a closer one.
So the tree takes its line width as a prop, and the scene feeds it the inverse of the current zoom. The drawing keeps one weight on screen while the geometry grows. Hand-drawn SVG makes this trivial, which is an argument for hand-drawn SVG: an exported asset would need vector-effect tricks or a redraw.
Everything is a pure function of the frame
src/motion.ts is 71 lines and has no state. Easing functions, a hand-rolled damped spring, and a path walker:
export const prog = (frame, from, to, ease = (t) => t) => ease(clamp01((frame - from) / (to - from)));
export const pop = (t, stiffness = 0.2, damping = 0.55) => {
if (t <= 0) return 0;
const w = Math.sqrt(stiffness);
return 1 - Math.exp(-damping * w * t) * Math.cos(w * t * Math.sqrt(1 - damping * damping));
};
The path walker is the one piece with real work in it. A Catmull-Rom spline through the flight path, sampled at 60 steps per segment, with the cumulative arc length precomputed so at(u) binary searches the length table instead of the parameter:
const at = (u: number): Pt => {
const target = clamp01(u) * total;
let lo = 0, hi = len.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (len[mid] < target) lo = mid; else hi = mid;
}
...
};
Walking a spline by its parameter instead of its length makes a flying bird speed up wherever you happened to place control points closer together. Walking it by arc length means an even glide, and the point spacing becomes a drawing decision rather than a timing one. heading(u) samples the curve 0.004 either side and returns atan2 in degrees, which is what rotates the bird to face where it is going.
No useState, no refs, no time. Frame 342 renders the same picture whether it is reached by playing from zero or by jumping straight to it, which is what makes the next part possible.
Two compositions that are only for me
Root.tsx registers three compositions. One is the reel. The other two never ship.
Sheet is a contact sheet: 18 frames of the reel, every frames apart from from, in a six by three grid, each tile labelled with its frame number in red. It uses Remotion's <Freeze> to pin each tile to its frame, and it is declared as long as the reel itself, because a frozen frame past the composition's end renders nothing. There is an 11-line shell script around it so checking a move is one command:
scripts/sheet.sh 236 2 # frames 236 to 270, every other one
scripts/sheet.sh 236 2 380 600 320 # the same, zoomed into one 9:16 area
Reviewing animation by watching the video is close to useless: you see that something is wrong and cannot see which frame it went wrong on. Eighteen stills side by side, with the numbers printed on them, turn "the landing looks odd" into "frame 251 has his foot through the branch".
Lab is a pose sheet. One wing beat in eight steps, a landing from flight to perched in eight more, then the peck, the envelope, the binoculars and the blink, each in its own labelled box:
const POSES: [string, Pose][] = [
...Array.from({ length: 8 }, (_, f): [string, Pose] => [`beat ${f}/8 (${beat(f).toFixed(2)})`, { fly: 1, flap: beat(f) }]),
...[0.85, 0.7, 0.55, 0.45, 0.3, 0.15, 0.05, 0].map((fly): [string, Pose] => [`fly ${fly}`, { fly, flap: 0.7 }]),
["peck", { lean: -25, head: -38 }],
...
];
The bird's pose is a plain object, so the sheet can ask for poses that never occur in the reel. It is one frame long, which is honest about what it is: a test fixture that happens to be rendered as a PNG.
Both of these exist because the renderer is deterministic. If frames were produced by a stateful animation loop, a still of frame 251 would not be reliable evidence about frame 251 in the video.
The render settings are about the second encode
Config.setVideoImageFormat("jpeg");
Config.setJpegQuality(95);
Config.setCodec("h264");
Config.setCrf(14);
Config.setPixelFormat("yuv420p");
CRF 14 is low for a 26 second clip and makes an 8.6 MB file, which is wasteful by every normal measure. The reason is that Instagram re-encodes whatever you upload, and the things that suffer are exactly what this reel is made of: 1px line art and a paper grain texture. Hand it a tightly compressed file and the second pass smears the hatching into mush. The upload budget is not the constraint; the platform's encoder is.
The frame is 1080 by 1920 and the layout is bounded by a constant rather than by taste:
export const SAFE = { top: 250, bottom: 1480, left: 80, right: 940 };
Instagram covers the top 250 pixels with its header, the bottom 440 with the caption and the right edge with its buttons. Anything that has to be read lives inside that box. A reel that looks balanced in the studio and has its call to action under the username is the most common way to waste a day of this work.
1,962 lines of TSX, six scenes, 772 frames, two throwaway compositions, and not one new drawing.
Top comments (0)