Documentation
Under the hood.
How this site animates scrolling, explained with demos running before your eyes and the real code from the repo. Read it all and you can rebuild it.
One value drives everything
When I decided to rebuild this portfolio, I started from a frustration: on most sites, you can feel the scroll. The page slides, the content moves up, and the illusion breaks. What I wanted was for scrolling to never scroll anything - just move an animation forward.
The whole solution fits in one sentence: I convert the scroll position into a number between 0 and 1, and that number drives absolutely everything. The 3D camera, the sections fading in, the progress bar, the asteroid physics. One source of truth, read once per frame. Here it is, live:
0.000
progress = scroll ÷ (total height − viewport height)
const progress = scrollY / (document.documentElement.scrollHeight - innerHeight);
// 0 en haut, 1 en bas. C'est tout. Vraiment.The smoothing
Native scrolling moves in steps - every wheel notch is a ~100px jump. If the camera follows that pixel-for-pixel, it jumps too, and there goes the cinematic feel. That's where Lenis comes in: it intercepts the wheel and eases the real scroll position toward the target with an interpolation.
The interpolation (lerp) principle is three words of code: each frame, travel 9% of the remaining distance to the target. Watch the difference - the top dot follows the raw value, the bottom one the smoothed version:
pos += (target − pos) × 0.09 — every frame
The bonus that changes everything: since Lenis smooths the scroll position itself, anything that reads it inherits the same inertia. I don't have ten easings to keep in sync - I have one. The complete file, singleton included (it's shared by every page):
src/lib/smooth.ts
/**
* Smooth scroll (Lenis), shared as a singleton.
*
* Everything animated on the site reads scroll from Lenis so motion stays
* on a single easing curve - that is what makes scrolling feel like one
* continuous animation instead of a page moving.
*/
import Lenis from 'lenis';
let lenis: Lenis | null = null;
let rafId = 0;
export function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Idempotent. Returns null when the user prefers reduced motion. */
export function ensureSmooth(): Lenis | null {
if (prefersReducedMotion()) return null;
if (lenis) return lenis;
lenis = new Lenis({
lerp: 0.09,
wheelMultiplier: 0.95,
// Touch keeps native scrolling; the scenes still read positions each frame.
});
const raf = (time: number) => {
lenis!.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
return lenis;
}
export function getSmooth(): Lenis | null {
return lenis;
}
export function destroySmooth(): void {
if (rafId) cancelAnimationFrame(rafId);
lenis?.destroy();
lenis = null;
rafId = 0;
}Gotcha
Remove scroll-behavior: smooth from your CSS when Lenis is active, otherwise the two smoothings stack and scrolling turns into a drunken boat.
Pinning a scene
Confession: I started with GSAP ScrollTrigger, like everyone. I uninstalled it an hour later. Not because it's bad - because I only needed one thing: knowing how far I am through a section. And the browser gives that away for free.
The recipe: a very tall section (say 280vh) with a 100vh position: sticky stage inside. While you traverse the section, the stage stays glued to the screen. The traversal progress is just geometry on getBoundingClientRect(). This demo is itself pinned - you are traversing it right now:
p = 0.00
The complete helper is 30 lines, and all four project scenes run on it:
src/lib/scrub.ts
/**
* Scroll-scrub helper for pinned scenes.
*
* A scene = a tall section with a sticky 100vh stage inside. This maps the
* section's traversal to a 0..1 progress each frame (cheap: only while the
* section is near the viewport). Works natively with Lenis since Lenis drives
* real window scrolling.
*/
export function scrub(section: HTMLElement, cb: (p: number) => void): () => void {
let raf = 0;
let lastP = -1;
const loop = () => {
const rect = section.getBoundingClientRect();
const vh = window.innerHeight;
if (rect.bottom > -vh && rect.top < vh * 2) {
const total = rect.height - vh;
const p = total > 0 ? Math.min(1, Math.max(0, -rect.top / total)) : 1;
if (p !== lastP) {
lastP = p;
cb(p);
}
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** Progress remapped to a [a,b] window with smoothstep easing. */
export const window01 = (p: number, a: number, b: number) => {
const t = clamp01((p - a) / (b - a));
return t * t * (3 - 2 * t);
};Why it works
getBoundingClientRect() stays accurate with Lenis because Lenis scrolls the real window, not a transformed wrapper. Many smooth-scroll libs break that property - it's THE criterion that made me pick Lenis.
A camera on rails
On the home page, progress doesn't fill a gauge: it places a camera on a curve. I drop one control point per section, gently zigzagging, and a Catmull-Rom threads a smooth curve through them all. Each frame: position = curve(p), gaze = curve(p + 0.045) - aiming slightly ahead of yourself is enough for a natural trajectory, like headlights in a bend.
solid dot = camera · small dot = gaze target, at p + 0.045 · beacons light up on approach
A detail I'm fond of: the timeline isn't hard-coded, it's read from the DOM. Each section carries data-seg and a data-weight (its relative duration) - to make the “projects” leg longer, I change an HTML attribute, not the engine:
src/components/scene/voyage.ts
// --- Timeline from the DOM ----------------------------------------------
const els = Array.from(container.querySelectorAll<HTMLElement>('[data-seg]'));
if (!els.length) return null;
const segments: Segment[] = els.map((el) => ({
id: el.dataset.seg!,
el,
weight: Number(el.dataset.weight ?? 1),
side: (el.dataset.side as Segment['side']) ?? 'center',
isProject: 'project' in el.dataset,
start: 0,
end: 0,
}));
const totalWeight = segments.reduce((s, x) => s + x.weight, 0);
let acc = 0;
for (const s of segments) {
s.start = acc / totalWeight;
acc += s.weight;
s.end = acc / totalWeight;
}
// Cinema mode on: sections become fixed overlays, the container becomes the timeline.
document.documentElement.classList.add('cinema');
container.style.height = `${totalWeight * 100 + 60}vh`; // Flight path: one control point per segment boundary, gently weaving.
const points: THREE.Vector3[] = [];
let cum = 0;
for (let i = 0; i <= segments.length; i++) {
points.push(new THREE.Vector3(7 * Math.sin(i * 1.9), 2.4 * Math.sin(i * 1.3 + 1), -cum * DEPTH));
cum += segments[i]?.weight ?? 1;
}
const path = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.35);
const END_Z = points[points.length - 1]!.z; // Camera along the path - with a per-segment dwell so each step locks.
let u = progress;
for (const s of segments) {
if (progress >= s.start && progress <= s.end) {
const span = s.end - s.start;
u = s.start + span * dwell((progress - s.start) / span);
break;
}
}
path.getPointAt(u, camera.position);
path.getPointAt(Math.min(1, u + 0.045), camTarget);
// Ease the gaze toward the active beacon.
for (const b of beacons) {
const mid = (b.seg.start + b.seg.end) / 2;
const w = 1 - Math.min(1, Math.abs(u - mid) / (b.seg.end - b.seg.start));
if (w > 0) camTarget.lerp(b.group.position, w * 0.35);
}
camera.lookAt(camTarget);
camera.position.y += Math.sin(elapsed * 0.7) * 0.12; // idle breathingTwo guard rails on top of that, added after the first tests. First a “dwell” curve: within each segment, the camera travels the first 38%, holds at the center, then moves on - a small wheel nudge while a card is displayed barely shifts anything. Then an idle snap: if scrolling stops mid-segment, we glide gently to its center to frame the step cleanly. It's a soft lock - any new scroll input takes over instantly:
// Idle snap: once the user pauses inside a segment, settle on its center
// so the current step is framed cleanly (a soft scroll lock, not a jail -
// any new scroll input takes over immediately).
if (Math.abs(v) > 1.5) {
idleTime = 0;
snapped = false;
} else {
idleTime += dt;
}
if (!snapped && idleTime > 0.35 && progress > 0.005 && progress < 0.995) {
const seg = segments.find((s) => progress >= s.start && progress < s.end);
if (seg) {
const center = seg.start + (seg.end - seg.start) * 0.5;
const off = Math.abs(progress - center);
if (off > 0.003 && off < (seg.end - seg.start) * 0.5) {
snapped = true;
lenis.scrollTo(center * maxScroll(), { duration: 0.9 });
}
}
}Sections that never scroll
That leaves the text. If the sections stayed in the flow, you'd see them scroll by - back to square one. So in cinema mode they all become position: fixed, stacked in the same spot, and progress decides which one exists: each has a [start, end] window on the timeline, with smoothstep fade-in and fade-out.
No CSS transitions, no animation lib: opacity and transform are recomputed every frame from p. And the crucial bit for accessibility and SEO: that CSS only exists in cinema mode. Without JS, without WebGL or with prefers-reduced-motion, the same sections stack into a normal page.
const applyOverlay = (p: number) => {
for (const s of segments) {
const span = s.end - s.start;
const local = (p - s.start) / span;
const first = s === segments[0];
const last = s === segments[segments.length - 1];
const inV = first ? 1 : smoothstep(0.04, 0.22, local);
const outV = last ? 1 : 1 - smoothstep(0.8, 0.97, local);
const vis = Math.max(0, Math.min(inV, outV));
const el = s.el;
if (local < -0.25 || local > 1.25) {
if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
continue;
}
el.style.visibility = 'visible';
el.style.opacity = String(vis);
const y = (1 - inV) * 52 - (1 - outV) * 52;
el.style.transform = `translateY(${y.toFixed(1)}px) scale(${(0.985 + 0.015 * vis).toFixed(3)})`;
el.classList.toggle('is-active', vis > 0.45);
}
if (progressBar) progressBar.style.transform = `scaleX(${p.toFixed(4)})`;
const active = segments.findIndex((s) => p >= s.start && p < s.end);
dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.max(0, active)));
};src/views/HomeView.astro (style)
/* Cinema: fixed overlays, driven per-frame by voyage.ts */
:global(html.cinema) .seg {
position: fixed;
inset: 0;
min-height: 0;
visibility: hidden;
opacity: 0;
pointer-events: none;
will-change: opacity, transform;
&.is-active {
pointer-events: auto;
}
}Scroll has a velocity
Position isn't the whole story: Lenis also exposes the velocity, signed. I smooth it into two “energies” - one for scrolling down, one for up - and wire them into the asteroid belt physics. Scrolling down agitates the rocks into collisions (elastic bounce + sparks); scrolling up sucks them into a vortex that brings them back into formation.
v = 0 px/frame → down energy 0.00 · up energy 0.00
The real engine code - the energy smoothing, then the collision loop (limited to rocks near the camera, otherwise the O(n²) bites):
src/components/scene/voyage.ts
// Scroll → timeline progress + belt energies (Lenis keeps this smooth).
const raw = lenis.scroll / maxScroll();
progress = Math.min(1, Math.max(0, raw));
const v = lenis.velocity; // px per frame, signed
downEnergy = lerp(downEnergy, Math.min(1, Math.max(0, v / 55)), 0.08);
upEnergy = lerp(upEnergy, Math.min(1, Math.max(0, -v / 55)), 0.08); // Collisions only while agitated, only near the camera (cheap N² slice).
if (agitated) {
for (let i = 0; i < AST; i++) {
const a = rocks[i]!;
if (Math.abs(a.pos.z - camera.position.z) > 60) continue;
for (let j = i + 1; j < AST; j++) {
const b = rocks[j]!;
const dz = a.pos.z - b.pos.z;
if (dz > 6 || dz < -6) continue;
const d2 = a.pos.distanceToSquared(b.pos);
const rr = a.r + b.r;
if (d2 < rr * rr) {
tmp.subVectors(a.pos, b.pos).normalize();
const va = a.vel.dot(tmp);
const vb = b.vel.dot(tmp);
a.vel.addScaledVector(tmp, vb - va);
b.vel.addScaledVector(tmp, va - vb);
a.pos.addScaledVector(tmp, rr - Math.sqrt(d2));
burst(tmp.copy(a.pos).lerp(b.pos, 0.5), 7);
}
}
}
}Drawing with scroll
My favorite SVG attribute: pathLength="1". It declares the path is 1 unit long, whatever its real length. So stroke-dasharray: 1 + a stroke-dashoffset going from 1 to 0, and any path draws itself - no getTotalLength(), no math, the same CSS for every path. That is what animates the nova-infra network diagram and the portfolio blueprint.
stroke-dashoffset: 1.00
src/components/scenes/InfraScene.astro (style)
.edge {
fill: none;
stroke: var(--nds-primary);
stroke-width: 1.5;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0.85;
}Sorting files
The file-organizer scene is FLIP in reverse. I first let the CSS grid lay the chips out (the FINAL state), measure those positions, freeze the board height, and only then absolutize them onto scattered positions. Scroll then interpolates each chip to its slot. The chaos comes from a seeded PRNG: same mess on every visit, which is more honest than a Math.random() shifting behind your back.
Images
photo.jpglogo.pngscan.webpCode
main.rslib.rsCargo.tomlSmall confession: my first bug here was resize - I re-measured the targets without releasing the board's frozen height. The chips were aiming at positions from a layout that no longer existed. Hence the board.style.height = '' on the first line of measure():
src/components/scenes/SorterScene.astro
/** Deterministic PRNG so the "chaos" looks identical on every visit. */
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let z = Math.imul(seed ^ (seed >>> 15), 1 | seed);
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
} const measure = () => {
// 1) Let the grid lay chips out sorted, measure targets.
root.classList.remove('is-ready');
board.style.height = '';
chips.forEach((c) => c.removeAttribute('style'));
const bRect = board.getBoundingClientRect();
board.style.height = `${bRect.height}px`;
const rng = mulberry32(20260702);
items = chips.map((el, i) => {
const r = el.getBoundingClientRect();
const tx = r.left - bRect.left;
const ty = r.top - bRect.top;
return {
el,
tx,
ty,
sx: rng() * (bRect.width - r.width),
sy: rng() * (bRect.height - r.height),
rot: (rng() - 0.5) * 34,
delay: i / chips.length,
};
});
// 2) Freeze the board height, absolutize the chips at their scatter.
root.classList.add('is-ready');
apply(lastP);
};The rest, briefly
The rest of the site is deliberately boring, and that's what lets the animation exist. Three gates open before cinema mode - reduced-motion, WebGL renderer creation, and only then the html.cinema class. If a single one stays shut, you get a complete static page with exactly the same content. Budget-wise: DPR capped at 2, half the particles on mobile, loop paused when the tab is hidden, and Three.js (a hefty 528 KB) behind a dynamic import - it only downloads if the voyage is actually going to start.
src/components/scene/voyage.ts
// --- Capability gate -----------------------------------------------------
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
} catch {
return null;
}
const isMobile = window.matchMedia('(max-width: 840px)').matches;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.75 : 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(BG, 1);Bilingual: French lives at the root, English under /en/, and every English route is four lines because all the logic sits in shared views. Dictionaries are typed - forget a key in English and the build refuses.
astro.config.mjs
i18n: {
defaultLocale: 'fr',
locales: ['fr', 'en'],
routing: {
prefixDefaultLocale: false,
},
},src/pages/en/index.astro
---
import HomeView from '../../views/HomeView.astro';
---
<HomeView />The design system provides the tokens (var(--nds-…)) and the Sass type mixins, wired through a loadPath - the site never hard-codes a color, 3D scenes included. Deployment: astro build outputs a static dist/, a Gitea Actions runner on my OVH VPS builds and publishes it behind Apache and Cloudflare.
astro.config.mjs
vite: {
plugins: [stripFontImport],
build: {
// Source maps help debugging in prod and satisfy Lighthouse's audit.
sourcemap: true,
},
css: {
preprocessorOptions: {
scss: {
loadPaths: [ndsTokens],
},
},
},
},Rebuild it
If I had to redo it all tomorrow, here's the order - it's almost the one I followed, minus the detours:
- The static page first. All sections, stacked, finished, good-looking. That is your fallback, your SEO, and your safety net for what follows.
- Lenis as a singleton (chapter 02). Copy smooth.ts, that is what it is for.
- The magic number: display the page progress in a corner (chapter 01). Until that number moves cleanly, nothing will.
- The camera (chapter 04): control points, the Catmull-Rom, position = curve(p). Watch it fly through the void before adding anything.
- The overlays (chapter 05): switch the sections to fixed behind an html.cinema class, never by default.
- The matter: stars, nebulae, belt. Then the velocity (chapter 06) - that is what makes the scroll feel alive.
- scrub.ts (chapter 03) and one pinned scene per project. The static fallback is written AT THE SAME TIME as the scene, not after.
- SEO, i18n, CI (chapter 09). Boring, indispensable.
- Test on a real trackpad, a real phone, and tune the data-weight values. Mine changed five times.
And if anything is still unclear: all the code is on my Gitea, and every snippet on this page is extracted from it at build time - what you read here is up to date by construction.