


How the whisky that ages as you scroll was built.
Everything behind vynce-hollin.pages.dev: the scroll-scrub code, the Blender script that rendered all 360 frames, and the exact brief we used. Use it on your own product today.
scrub.js (about 90 lines, no libraries), render/hollin.py (the full Cycles scene), encode.sh and PROMPT.md. MIT licence.The idea in one line
Don’t animate the page. Render the film once, properly, then let the visitor’s scroll be the playhead.
Five steps
- Render a sequence. 150 to 400 frames of one slow, continuous change. Hollin is 360 frames of Cycles: the spirit deepens by volume absorption, the level drops by the angels’ share (0.98 per year, solved on the glass’s real profile), the sun walks from cold dawn to amber dusk.
- Encode two tiers. WebP at 1080 and 720 wide. About 18 MB for the 720 tier, loaded progressively.
- Pin a canvas in a tall track. A sticky full-screen canvas inside a section ten screens tall. Scroll position becomes progress 0 to 1, progress becomes a fractional frame index.
- Ease and crossfade. The shown progress eases toward the scroll (about 120 ms), and frame i is drawn with frame i+1 on top at the fractional alpha. That is the whole difference between a flipbook and something that feels like film.
- Load coarse to fine. Every 32nd frame first, then every 16th, 8th and so on, always drawing the nearest frame you have. The page is usable in a second and sharp in a few.
The code
The core, exactly as it ships in the kit. Drive every number and line of copy from onProgress(t) so nothing drifts out of sync.
// scrub.js: scroll-scrubbed image sequence (the technique behind vynce-hollin.pages.dev). No libraries. MIT, by Vynce Digital.
//
// <section id="track" style="height:800vh"><div style="position:sticky;top:0;height:100svh"><canvas id="film"></canvas></div></section>
// import { scrub } from "./scrub.js";
// scrub({ canvas: "#film", track: "#track", count: 360, url: (i) => `frames/${String(i).padStart(4, "0")}.webp`,
// onProgress: (t) => { /* 0..1, drive your copy, counters, colours from here */ } });
//
// The four things that make it feel like video instead of a flipbook:
// 1. a sticky canvas inside a tall track: scroll position -> progress 0..1 -> frame index
// 2. smoothing: the shown progress eases toward the scroll target, so trackpads and flicks feel physical
// 3. crossfade: a fractional frame index draws frame i, then frame i+1 at alpha = fraction (smooth at any scroll speed)
// 4. coarse-to-fine loading: every 32nd frame first, then 16th, 8th ... so any position has a near frame within a second
export function scrub({ canvas, track, count, url, onProgress = () => {}, hold = [0.03, 0.08], smooth = 120, fit = "cover" }) {
const cv = typeof canvas === "string" ? document.querySelector(canvas) : canvas;
const tr = typeof track === "string" ? document.querySelector(track) : track;
const ctx = cv.getContext("2d", { alpha: false });
const frames = new Array(count).fill(null);
let W = 0, H = 0, cur = 0, last = 0, dirty = true;
const order = [];
for (const st of [32, 16, 8, 4, 2, 1]) for (let i = 0; i < count; i += st) if (!order.includes(i)) order.push(i);
if (!order.includes(count - 1)) order.splice(1, 0, count - 1);
let k = 0;
const load = async () => {
while (k < order.length) {
const i = order[k++];
try {
const b = await (await fetch(url(i))).blob();
frames[i] = await createImageBitmap(b);
dirty = true;
} catch (e) { /* the nearest loaded frame stands in */ }
}
};
for (let n = 0; n < 5; n++) load();
const nearest = (i) => { if (frames[i]) return frames[i]; for (let d = 1; d < count; d++) { if (frames[i - d]) return frames[i - d]; if (frames[i + d]) return frames[i + d]; } return null; };
const resize = () => { const dpr = Math.min(2.5, devicePixelRatio || 1); W = cv.width = Math.round(cv.clientWidth * dpr); H = cv.height = Math.round(cv.clientHeight * dpr); dirty = true; };
const draw = (img, a) => {
if (!img) return;
const s = fit === "cover" ? Math.max(W / img.width, H / img.height) : Math.min(W / img.width, H / img.height);
const dw = img.width * s, dh = img.height * s;
ctx.globalAlpha = a; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); ctx.globalAlpha = 1;
};
const clamp = (x) => Math.min(1, Math.max(0, x));
const target = () => {
const r = tr.getBoundingClientRect();
const s = clamp(-r.top / (r.height - innerHeight));
return clamp((s - hold[0]) / (1 - hold[0] - hold[1])); // hold a beat at the start and the end
};
const tick = (now) => {
const dt = Math.min(64, now - (last || now)); last = now;
const t = target(), prev = cur;
cur += (t - cur) * (1 - Math.exp(-dt / smooth));
if (Math.abs(t - cur) < 1e-5) cur = t;
if (cur !== prev || dirty) {
const f = cur * (count - 1), i = Math.floor(f), a = f - i;
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, W, H);
draw(nearest(i), 1);
if (a > 0.002 && frames[i + 1]) draw(frames[i + 1], a);
onProgress(cur);
dirty = false;
}
requestAnimationFrame(tick);
};
addEventListener("resize", resize); resize(); requestAnimationFrame(tick);
}
The render
Free software, no downloaded models: Blender’s Cycles renderer and one Python file that builds the glass, the cask, the warehouse and the light from code.
Preview stills, then the sequence
blender -b -P render/hollin.py -- --mode still --out out/test --res 540x960 --samples 48 --t 0,0.5,1 blender -b -P render/hollin.py -- --mode seq --out out/seq --res 1080x1920 --samples 96 --n 360 blender -b -P render/hollin.py -- --mode clay --out out/pass --t 1 blender -b -P render/hollin.py -- --mode wire --out out/pass --t 1
What made it look real
- The spirit is its own mesh, 0.06 mm inside the glass wall, with a meniscus, IOR 1.36 and colour from volume absorption, so thick parts read darker than thin ones, as in a real glass.
- No black glass shadows. Shadow rays see glass and spirit as transparent (tinted by the spirit’s volume), so the low sun throws an amber pool on the cask head.
- Dull old wood. Rough, low-specular oak with per-stave tone and grime. Shiny wood at grazing angles turned every cask head into a flat grey disc.
- One fixed noise seed and a slow camera. Neighbouring frames are nearly identical, so the crossfade is invisible and nothing shimmers when you scrub.
The brief we used
We build with an AI agent (Claude Code) driving Blender on a GPU. This is the brief, cleaned of internal paths. Swap the bracketed parts for your product.
# The brief behind Hollin 12
This is the brief we gave our AI build agent (Claude Code, with Blender on a GPU machine), cleaned of our internal file paths.
Swap the bracketed parts for your own product. It works because it fixes the decisions an agent would otherwise guess.
---
Build a premium, photoreal, live website for [a fictional single malt whisky, "Hollin 12", cask 1207, filled 2014]
where scrolling drives one physical transformation with a light and colour arc:
[the clear new-make spirit deepens to amber through twelve years in a cask, while the light goes from cold warehouse dawn
to warm amber dusk].
It must never look cartoonish: no toy 3D, no game HUD, no stock icons, no gradient cards, no emoji. Real-brand quality,
editorial typography, one consistent design system, real trade copy researched from real [distilleries]. Every number on
the page must be true or computed (e.g. the angels' share: about 2 % a year, so 1 - 0.98^12 = 21.5 % after twelve years).
1. Pre-render the transformation as a photoreal Blender Cycles image sequence, fully procedural Python (no downloaded
models): [a Glencairn glass lathed from a measured profile, the spirit as a separate mesh 0.06 mm inside the glass wall
with volume absorption for colour, an upright 200-litre ex-bourbon cask with 32 staves, iron hoops and a stencilled
head, racks of casks on timber rails, a stone warehouse with one barred window and volumetric haze].
- Animate per frame from t = 0..1, not with keyframes: colour (fast pick-up early, then slower), the level by volume
(solve the fill height on the real glass profile), the sun's elevation and colour, exposure, a slow camera dolly.
- The light is the story: the beam walks down the aisle and reaches the glass in the last year.
- Check stills at t = 0, 0.5, 1 at low resolution first and fix what looks CG (striped wood, flat discs, black glass
shadows: use transparent shadow rays for glass and spirit) before rendering the full sequence.
- 1080 x 1920, 96 samples, OpenImageDenoise, fixed seed (no shimmer when scrubbing). 360 frames. Render coarse-to-fine.
- Also render the last frame as a clay pass and a wireframe pass, pixel-aligned, for a "hold to see the render" moment.
2. Encode two WebP tiers (1080 and 720 wide).
3. Build the page (vanilla JS, no framework):
- a sticky full-screen canvas inside a ~10-screen-tall track; scroll -> progress -> fractional frame index
- ease the shown progress toward the scroll (time constant ~120 ms); crossfade frame i and i+1 by the fraction
- hold year 0 for the first 3.5 % of the track (the headline) and year 12 for the last 10 % (the call to action)
- load frames coarse-to-fine (every 32nd first) and always draw the nearest loaded frame
- chapters of copy are bands of years with soft crossfades; a two-digit odometer for years; the angels' share live;
a thin timeline from "Filled 2014" to "2026" with one accent colour meaning "time"
- the headline at the top of the page is the hook: [This whisky ages as you scroll.]
- mobile first; desktop shows the film as a portrait panel with its own blurred colour as the ambient background
4. Deploy to [your-project].pages.dev and check it on a real phone.
---
Our additions after the first pass: darker, rougher wood (specular level 0.28) because grazing reflections made the
cask heads read as flat grey discs; no wave texture in the grain (it looked like op-art); the denoiser on the CPU when
the GPU is shared.
Want one for your brand?
We’ll sketch a homepage concept around what you sell, free. Or comment BUILD under the reel.