Writing
I Draw My Portfolio With Code
Every portfolio has a hero section. Mine has a cartoon.
When you open the homepage, a two-and-a-half-minute animated intro plays: my career story told in eight sketch-style chapters, narrated, with a pixel-bot mascot named Riko. The obvious assumption is "he exported a video". It is not a video. There is no video file in the app at all - every frame is drawn by TypeScript on a <canvas> element, in real time, on your machine. About 2,700 lines across three files: the drawing engine, the storyboard, and the mascot.
This post is about how that works - the wobble trick, the scene format, and why drawing instead of exporting turned out to be the better deal.
The effect: lines that refuse to sit still
Hand-drawn animation has a secret ingredient: imperfection. In frame-by-frame cartoons, artists re-draw every frame by hand, and the tiny differences between drawings make the drawing feel alive - the industry term for the wiggle is "line boil".
To fake that in code you need two ingredients: jitter (each redrawn line is slightly different) and determinism (the same line wobbles the same way every cycle, or shapes fall apart instead of boiling). My engine does it with a seeded hash instead of Math.random():
let boil = 0
export function tickBoil(): void { boil++ }
/* deterministic wobble for a named anchor, changing each boil frame */
export function jit(id: number, amp = 1.4, axis = 0): number {
let h = Math.imul(id * 2654435761 + boil * 40503 + axis * 97, 2246822519)
h ^= h >>> 13
h = Math.imul(h, 3266489917)
h ^= h >>> 16
const f = (h >>> 0) / 4294967296
return (f - 0.5) * 2 * amp
}
Every drawable element carries a stable id (usually a hash of a string key, so a rectangle called "card" always wobbles around its own anchor). boil ticks a few times a second; each tick, the hash output changes, and every line redraws itself with a new-but-reproducible offset. That single function is half the personality of the whole site.
Drawing a sketchy line
Given jitter, a "hand-drawn" line is just a straight line divided into segments, each nudged perpendicular to its direction - plus two details that sell the illusion:
export function skLine(ctx, x1, y1, x2, y2, o: LineOpts = {}): void {
const id = o.id || (o.key !== undefined ? hashCode(o.key) : uid())
const dx = x2 - x1, dy = y2 - y1
const len = Math.hypot(dx, dy) || 1
const nx = -dy / len, ny = dx / len // perpendicular
const segs = Math.max(2, Math.min(26, Math.round(len / 22)))
const over = Math.min(12, len * 0.05) * (o.overshoot === false ? 0 : 1)
// lines start slightly *before* and end slightly *past* their endpoints,
// the way a real pencil stroke does
const ax = x1 - (dx / len) * over, ay = y1 - (dy / len) * over
const bx = x2 + (dx / len) * over, by = y2 + (dy / len) * over
const amp = o.amp ?? 1.5
const pts: Pt[] = []
for (let i = 0; i <= segs; i++) {
const t = i / segs
pts.push([
lerp(ax, bx, t) + nx * jit(id, amp, i) + (dx / len) * jit(id, amp * 0.6, i + 50),
lerp(ay, by, t) + ny * jit(id, amp, i) + (dy / len) * jit(id, amp * 0.6, i + 50),
])
}
// ...stroke the polyline, then a second ghost pass
}
Segment count scales with length (2 to 26), so long lines curve and short lines stay crisp. And after the main stroke, a second pass draws a fainter, wider ghost line - that double-stroke look is what marker-on-paper actually leaves behind. On top of skLine sit the rest of the toolbox: skRect, skEllipse, hatching, arrows, speech bubbles, chips, a marker underline, a browser frame. Everything on screen is a composition of those.
Scenes as functions of progress
With primitives done, the movie itself is a list of scenes, and each scene is just a draw function of time:
export interface Scene {
id: string
label: string
dur: number // seconds
trans: TransitionKind | null // inkSweep, scribble, torn, zoom, flash, flip
bg: StageBg // paper or navy
draw: (ctx: CanvasRenderingContext2D, p: number, api: SceneApi) => void
}
p goes from 0 to 1 across the scene, and a tiny phase() helper slices it into windows - "fade the title in during the first 20%, slide the card between 40% and 70%":
const title = phase(p, 0, 0.2, 'outCubic')
const cardY = phase(p, 0.4, 0.7, 'outBack')
Because everything is a pure function of p, the whole player gets pause, scrub and replay for free - and the same scene code that plays in the browser also renders the MP4 exports frame by frame under skia-canvas + ffmpeg. One implementation, two outputs.
The storyboard file is the fun part to read: eight chapters, each a draw function that composes primitives - my career timeline, the Traveloka booking flow, the Blibli payment screens, the product tour - with a virtual camera (pan, zoom, shake) gliding over a fixed 1600×900 stage that letterboxes itself to portrait on phones.
Sound without audio files
The narration is pre-rendered by a neural TTS voice into a single audio file, with a generated timings file that says which caption word starts at which millisecond - so captions can highlight karaoke-style with zero manual sync work. Sound effects are the opposite: there are no sound files for them at all. They are synthesized on the fly with the Web Audio API - pops, ticks and whooshes are oscillators and noise buffers - triggered by the same phase() windows, with a fire-once guard so a scrubbed timeline cannot make a sound effect play twice.
Why draw instead of export?
Honest accounting. Exporting a video would have been faster to produce. But drawing won on every axis that matters after launch:
- Weight - the intro costs zero megabytes of media; it is JavaScript that ships anyway. The narration file is the only real asset.
- Live text - the storyboard can read from the same data as the site, so the intro updates when my experience does. No re-render, no re-upload.
- Interactivity - chapters are clickable, the timeline is scrubbable, and
prefers-reduced-motionswaps the boil for a static frame, all because it is code and not a flattened MP4. - The hire signal - this is the part I cannot overstate. "I built fast websites" is a claim; a portfolio that draws itself is a demo. The intro is my resume rendered as a canvas app.
If you want to see the effect in motion, the intro plays on the homepage - and if you skip it, no hard feelings, Riko will wait by the footer.
Thanks for reading!