Vox explainers have a look you recognise in one frame. A scanned document, a yellow highlighter swiping across one sentence, a red marker circling a number, a black-and-white cut-out of someone with a white sticker border and a piece of masking tape holding it down. Then the camera pushes in on the line that matters.
I wanted my portfolio to feel like that. Not a video about it: the page itself, playing as you scroll. It's live at avivashishta.com/lab/explained, and the window above this post is that page running.
The short version: Codex generated every analog asset (paper, tape, torn edges, a highlighter stroke, a marker circle, an arrow, a push-pin, a map and a halftone cut-out of my avatar). Each chapter is a tall section with a pinned stage. One loop writes a single number per chapter, --p, and CSS turns it into every highlight, slap, pin drop and camera move.
What makes something look like Vox
Before writing code I listed what you actually see in those videos, because "make it look like Vox" is too vague for a model:
- Paper everywhere. Backgrounds are scanned paper, not flat colour. Things are cut out, torn and taped down.
- The highlighted document. A real-looking page of text, and a highlighter swipes the key phrase while the camera zooms in on it.
- Marker on top. Red circles, arrows and handwritten notes drawn over the footage.
- Halftone cut-outs. People and objects as black-and-white newspaper prints with a thick white border.
- Chapter cards. A full-screen colour block with a number and a word, then it tears away.
- Charts and maps that build. Bars grow, pins drop, callouts slap in.
- Texture that moves. Grain and a slight wobble on the ink, because it was drawn by hand.
Every one of those became a part of the page: the numbers are a highlighted document, Build is a timeline with pinned screenshots, Brand is a bar chart with the brand clippings taped over it, and Learn is a map with a pin in India.
Generating the assets with Codex
I didn't want stock textures, so I asked the Codex CLI (codex exec, which has image generation built in) for each piece. A small script runs them all in parallel:
# gen.sh name "brief" [reference image]
codex exec --skip-git-repo-check -C "$OUT" --image "$REF" -- \
"Use your image generation tool to create exactly ONE image, then copy the PNG to $OUT/raw/$NAME.png. \
Style context: an asset for a Vox-style explainer page (paper collage, halftone print, highlighter, marker). \
Image brief: $BRIEF"
One gotcha: --image takes several files, so without the -- it swallows your prompt as a second image path and Codex exits with "No prompt provided".
The trick that made the assets usable is asking for backgrounds that are easy to remove:
- Ink on white for anything I want to recolour. The highlighter stroke, the marker circle, the arrow and the torn paper strip are black on white. A few lines of Python turn brightness into alpha, and the result becomes a CSS mask, so one torn strip can be blue, red or yellow.
- Solid magenta or green for real objects. Tape, the push-pin and the cut-out are on
#FF00FFor#00FF00, then chroma-keyed with a despill so the edges don't glow pink. - A reference image for the cut-out. I passed my illustrated avatar with
--imageand asked for the same face as a halftone newspaper print with a hand-cut white border. It kept the curls and the glasses.
PAPER: A seamless, tileable, square scan of plain uncoated off-white paper (very light warm grey), with subtle fibres and slight grain. No folds, stains, text, shadows or vignette. Evenly lit and flat.
HIGHLIGHTER: A single straight horizontal stroke of a broad chisel-tip highlighter, solid black ink on pure white, swiped once left to right: flat edges, faint streaks along the stroke, a darker blob where it starts and an angled chisel end. Wide 3:1, nothing else.
MARKER CIRCLE: A single loose ellipse drawn with a thick black felt marker on pure white in one quick motion, ends overlapping and overshooting, like circling a word in a newspaper. Wide 2:1.
TORN STRIP: A long horizontal strip of paper with both long edges torn by hand, fibrous and jagged. The strip is solid black on pure white, no shadow.
TAPE: Three separate pieces of beige semi-translucent masking tape, torn at both ends and slightly wrinkled, photographed flat from above on a perfectly flat solid magenta (#FF00FF) background, no shadows.
CUT-OUT (attach a photo or avatar): Turn the attached portrait into a collage cut-out: a black-and-white newspaper halftone print with coarse visible dots, the same pose and face, surrounded by a thick, slightly irregular hand-cut white paper border. Solid magenta (#FF00FF) background, no shadow.
MAP: A vintage printed map of South Asia centred on India, Vox explainer style: India in a muted warm grey, neighbours lighter, thin dark borders, halftone dots on the ocean, no labels or text.
The prompt for the page
Paste this into Claude Code, Cursor or Replit along with your own content. It describes the system, not my files.
Build a scroll-driven page that looks like a Vox explainer video. React and plain CSS, no animation library and no canvas. I have these assets in /public/vox: paper.webp (tileable), highlighter.webp, circle.webp, arrow.webp and torn.webp (black shapes on transparent, to use as CSS masks), tape-1..3.webp, pin.webp, map.webp and a halftone cut-out of me.
STRUCTURE: a title card, then one section per chapter, then an end card. Each chapter is a section whose height is N screens (calc(var(--len) * 100svh)) with a child stage that is position: sticky, top 0, 100svh tall, overflow hidden. The stage is what the viewer watches; the section's height is how long it plays.
ONE NUMBER PER CHAPTER: a single requestAnimationFrame loop measures each section and writes its progress as a CSS variable --p (0 when the stage pins, 1 when it lets go). Don't jump to the scroll position: ease toward it every frame with p += (target - p) * (1 - exp(-dt * 9)), so motion glides at any scroll speed and frame rate. Skip sections more than a screen away, and skip the write when the value hasn't changed.
TIMED PIECES IN CSS: give any element a window with inline --a and --b, and one class computes its own progress: --t: clamp(0, (var(--p) - var(--a)) / (var(--b) - var(--a)), 1). Everything else only reads --t. The default entrance is a slap: opacity clamp(0, t * 4, 1), and with --e = (1 - t) * (1 - t), rotate(var(--r) - 6deg * e) scale(1 + .3 * e), so it lands oversized and turned and eases out.
THE HIGHLIGHTER: a mark element whose ::before is yellow, masked by highlighter.webp stretched to 100% 100%, with clip-path: inset(0 calc((1 - var(--t)) * 100%) 0 0) so it swipes left to right. mix-blend-mode: multiply so the text stays crisp on top. Keep each highlighted phrase on one line (white-space: nowrap).
THE MARKER CIRCLE: red background, two masks intersected: circle.webp and conic-gradient(#000 calc(var(--t) * 360deg), transparent 0). It draws itself around the number.
THE CAMERA: a wrapper with data-cam holding keyframes [p, x%, y%, scale, rotate]. The same loop interpolates them with smoothstep and writes the transform. Use it to push in on each highlighted line in turn, and to pan a three-panel timeline sideways. On phones multiply the zoom, drift and rotation by about .35, but not the panel pans. Don't put will-change on a camera that zooms, or the text scales as a blurry bitmap.
CHAPTER CARDS: each chapter opens on a full-bleed card in that chapter's colour with a big number and title. It slides up and away as --t of its window (.06 to .14) runs, and a torn strip in the same colour hangs off its bottom edge, so it looks torn off.
TEXTURE ON TWOS: only the texture runs at 12 frames a second. An SVG filter (feTurbulence + feDisplacementMap, scale 4) gives hand-drawn ink a wobble; reseed it every other texture frame. Apply it to static ink only (circles, arrows, torn edges), never to something animating every frame. Give every stage a grain layer whose background-position jumps each texture frame, and a soft inset vignette.
REDUCED MOTION: no loop, --p is 1 everywhere, sections are their natural height, stages are not sticky, and the timeline panels stack vertically, so everything is visible and nothing moves.
Build the clock and the --t system first, then the highlighted document chapter, then the rest.
Concept 1: one variable per chapter
The whole page runs on this:
.k { --t: clamp(0, (var(--p) - var(--a, 0)) / (var(--b, 1) - var(--a, 0)), 1); }
--p lives on the chapter. Each piece declares its own window inline, style={{ "--a": .2, "--b": .3 }}, and gets a private --t that runs from 0 to 1 inside it. Custom properties resolve where they are declared, so a highlight inside a callout inside the camera still gets its own --t from the same --p.
That made choreography a matter of picking numbers. The first highlight in the numbers chapter is .2 to .3, the camera arrives on that line at .2, and the second highlight starts at .28, while the camera is still settling. Scroll back up and everything rewinds, because it is a function of scroll and not a timeline that played once.
Concept 2: stepped texture, smooth motion
My first version moved everything at 12 frames a second, which is what Vox does: graphics animated on twos so they feel hand-made. In a video that works. On a page you scroll with your own hand, it felt like lag. The page jumped behind your finger.
So I split it. Motion is smooth, texture is stepped. The loop eases --p toward the scroll position every frame, while the marker boil and the grain update twelve times a second:
const follow = 1 - Math.exp(-dt * 9);
beat.p += (target - beat.p) * follow; // every frame: the camera glides
if (now - lastTexture > 1000 / 12) { // on twos: the paper feels alive
turbulence.setAttribute("seed", String(frame % 7));
stage.style.setProperty("--grain-x", `${(frame * 37) % 240}px`);
}
You still get the handmade wobble on every circle and torn edge, but nothing you control stutters.
Concept 3: assets as masks, colour from CSS
Every hand-drawn asset is black on transparent and used only as a mask:
.torn { background: var(--c); mask: url("/vox/torn.webp") 0 50% / 100% 100% no-repeat; }
One torn strip becomes the blue, red and yellow bars on the title card, the ripped bottom edge of every chapter card, and the three doors on the end card. The colours stay the site's own tokens, so it still looks like my site and not a template.
The marker circle uses two masks at once, the scanned ellipse and a conic sweep, intersected:
mask: url("/vox/circle.webp") center / 100% 100% no-repeat,
conic-gradient(from 20deg, #000 calc(var(--t) * 360deg), transparent 0);
mask-composite: intersect;
As --t goes to 1 the sweep opens and the circle looks like it's being drawn.
Concept 4: the camera is just keyframes
The document doesn't move. A wrapper around it does, through keyframes written as data:
<div data-cam={JSON.stringify([
[.12, 0, 0, 1.25, -2], // start close and tilted
[.2, 8, 14, 1.55, -1], // push in on line one
[.46, -6, 2, 1.5, 0], // drift to line two
[.92, 0, 0, 1, 0], // pull out to the whole page
])}>
Between two keyframes the loop uses smoothstep, so the camera always eases in and out like a real move. The Build timeline is the same thing with whole-panel pans: three panels side by side, and the camera holds on each company while its screenshot, pin, card and stat land.
Concept 5: real data, real links
Nothing on the page is typed in by hand. The document reads data/stats.json, the timeline reads the same list as the Build page, the brand clippings come from the media kit's markdown files, the chart ranks every Reel by views, and the headlines are the latest posts. When I update my numbers, the explainer updates too.
Every chapter also ends on a door to the real page: Build, the media kit, or Learn. The explainer is a trailer for the site, not a dead end.
That also led to a small feature across the blog: posts can have real outputs. Anything I build for a post lives on the site under /lab, and the post names it with output: "/lab/explained" in its frontmatter. The post then opens with the live page running in a window, and the page links back to its post. The Deal hero post and the homepage ring post do the same now.
What I'd steal from this
Even if you never build a Vox page, the --p and --t pattern is worth keeping. One number per section from JavaScript, everything else in CSS. It's scrubbable, reversible and cheap, and choreography becomes a list of windows you can read at a glance.