Abhishek Thakur
← Devlog

Build 3 min read

My Vox-Style Portfolio, With Every Paper Asset Drawn in Code

A scroll-driven explainer page built on one number per chapter, and paper, tape, ink and a halftone cut-out generated with Python instead of an image model.

My portfolio is a scroll-driven page that looks like a Vox explainer: scanned paper, a yellow highlighter swiping across a line, a red marker circling a number, a halftone cut-out of me taped down. The idea and the page system come from Avi Vashishta’s write-up. The difference is the assets: he generated them with an image model. I drew every one of them in code.

One number per chapter

Each chapter is a tall section with a pinned, full-screen stage inside it. One requestAnimationFrame loop measures every section and writes a single CSS variable, --p, that runs from 0 when the stage pins to 1 when it lets go. It eases toward the scroll position instead of jumping, so motion glides at any scroll speed:

const follow = 1 - Math.exp(-dt * 9)
beat.p += (target - beat.p) * follow

Everything else is CSS. Any element gets a window, --a to --b, and one class turns the chapter’s --p into its own private progress:

.k {
  --t: clamp(0, (var(--p, 0) - var(--a, 0)) / (var(--b, 1) - var(--a, 0)), 1);
}

The highlighter reads --t to swipe left to right, the marker circle reads it to draw itself, and the camera reads keyframes for the same --p. Scroll back up and it all rewinds, because it is a function of scroll, not a timeline.

flowchart LR
  S[Scroll position] --> L[rAF loop<br/>eases toward target]
  L --> P["--p per chapter"]
  P --> T1["--t: highlighter"]
  P --> T2["--t: marker circle"]
  P --> T3["--t: slap entrance"]
  L --> C[Camera keyframes<br/>smoothstep]

Drawing the assets instead of generating them

The original pipeline asks an image model for each asset. I didn’t have one available, so each asset became a short Python function with Pillow, NumPy and SciPy:

AssetHow it is drawn
PaperPeriodic noise (gaussian_filter with mode='wrap') plus faint fibres, so the tile has no seams
HighlighterA polygon with an angled chisel end, streaks from noise stretched along the stroke
Marker circleOverlapping discs stamped along a loose spiral that overshoots its start
Torn stripEdges from coarse plus fine noise, with fibres poking out
TapeSemi-transparent polygon with torn ends, crepe texture and a few wrinkles
Cut-outMy photo, background removed, re-screened as 45-degree halftone dots with a hand-cut border
MapReal country borders from Natural Earth, halftone dots on the ocean only

The ink assets are black on transparent, so the page uses them as CSS masks and picks the colour itself. One torn strip becomes every chapter card edge and every coloured door at the end.

The bug that only showed up when rotated

The marker circle was two masks intersected: the scanned ellipse and a conic sweep that opens as --t goes to 1. In isolation it rendered perfectly. On the page, a faint red box appeared around the number.

The difference was the camera. The document sits inside a wrapper that zooms and rotates, and Chrome leaked a hairline of the red fill at the layer edge when it composited two intersected masks under a rotation. The fix was to stop asking for the intersection: I pre-coloured the ellipse red and kept a single conic mask.

.ring::after {
  background: url('/vox/circle-red.webp') center / 100% 100% no-repeat;
  mask: conic-gradient(from 290deg, #000 calc(var(--t) * 360deg), transparent 0);
}

I only found it because I screenshot every chapter at several scroll positions and zoomed in. A test that only checks “does it render” would have passed.

How this devlog publishes

The site is Astro on GitHub Pages. Posts are Markdown files in the repo, so publishing is a push:

sequenceDiagram
  participant Me
  participant GitHub
  participant Actions as GitHub Actions
  participant Site as iamabhishekt.github.io
  Me->>GitHub: git push (new .md file)
  GitHub->>Actions: run "Deploy to GitHub Pages"
  Actions->>Actions: astro build
  Actions->>Site: deploy, about a minute later

What I’d keep from this even without the paper look: one number per section from JavaScript, everything else in CSS. It is scrubbable, reversible and cheap, and the choreography is a list of windows you can read at a glance.

Keep reading