Writing

I Draw My Portfolio With Code

Posted · 6 min read#canvas#javascript#next

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-motion swaps 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!