Documentation

Sous le capot.

Comment ce site anime le scroll, expliqué avec des démos qui tournent sous vos yeux et le vrai code du dépôt. Si vous lisez tout, vous savez le refaire.

Three.jsLenisAstro 7NDS v1.2.1Lecture ~20 min, molette obligatoire

01

Une seule valeur pilote tout

Quand j'ai décidé de refaire ce portfolio, je suis parti d'une frustration : sur la plupart des sites, on sent le scroll. La page glisse, le contenu monte, et l'illusion se casse. Ce que je voulais, c'est que scroller ne fasse jamais défiler quoi que ce soit - juste avancer une animation.

La solution tient en une phrase : je convertis la position de scroll en un nombre entre 0 et 1, et ce nombre pilote absolument tout. La caméra 3D, l'apparition des sections, la barre de progression, la physique des astéroïdes. Une seule source de vérité, lue une fois par frame. Le voilà, ce nombre, en direct :

Démo - ça tourne en vraila progression de CETTE page

0.000

progress = scroll ÷ (hauteur totale − hauteur de fenêtre)

const progress = scrollY / (document.documentElement.scrollHeight - innerHeight);
// 0 en haut, 1 en bas. C'est tout. Vraiment.
02

Le lissage

Un scroll natif avance par crans - chaque coup de molette est un saut de ~100px. Si la caméra suit ça au pixel près, elle saute aussi, et adieu le cinéma. C'est là que Lenis entre en jeu : il intercepte la molette et fait glisser la vraie position de scroll vers la cible avec une interpolation.

Le principe de l'interpolation (lerp), c'est trois mots de code : à chaque frame, on parcourt 9% du chemin restant vers la cible. Regardez la différence - le point du haut suit la valeur brute, celui du bas la version lissée :

Démo - ça tourne en vraiScrollez, regardez
brut
lerp 0.09

pos += (target − pos) × 0.09 — chaque frame

Le bonus qui change tout : comme Lenis lisse la position de scroll elle-même, tout ce qui la lit hérite de la même inertie. Je n'ai pas dix easings à synchroniser - j'en ai un. Le fichier complet, singleton compris (il est partagé par toutes les pages) :

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;
}

Piège

Enlevez scroll-behavior: smooth de votre CSS quand Lenis est actif, sinon les deux lissages s'empilent et le scroll devient un bateau ivre.

03

Épingler une scène

Confession : j'ai commencé avec GSAP ScrollTrigger, comme tout le monde. Je l'ai désinstallé au bout d'une heure. Pas parce qu'il est mauvais - parce que je n'avais besoin que d'une chose : savoir où j'en suis dans la traversée d'une section. Et ça, le navigateur le donne gratuitement.

La recette : une section très haute (disons 280vh), et dedans, un étage en position: sticky de 100vh. Pendant qu'on traverse la section, l'étage reste collé à l'écran. La progression de la traversée, c'est juste de la géométrie sur getBoundingClientRect(). Cette démo est elle-même épinglée - vous êtes en train de la traverser :

Démo - ça tourne en vraicette boîte est sticky, la section derrière fait 220vh

p = 0.00

Le helper complet fait 30 lignes, et les quatre scènes projet tournent dessus :

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);
};

Pourquoi ça marche

getBoundingClientRect() reste juste avec Lenis parce que Lenis fait défiler la vraie fenêtre, pas un wrapper en transform. Beaucoup de libs de smooth scroll cassent cette propriété - c'est LE critère qui m'a fait choisir Lenis.

04

Une caméra sur des rails

Sur l'accueil, le progress ne remplit pas une jauge : il place une caméra sur une courbe. Je pose un point de contrôle par section, en zigzag doux, et une Catmull-Rom fait passer une courbe lisse par tous. À chaque frame : position = courbe(p), regard = courbe(p + 0.045) - viser un peu devant soi suffit à donner une trajectoire naturelle, comme des phares dans un virage.

Démo - ça tourne en vraila même mécanique, vue de dessus

point plein = caméra · petit point = cible du regard, à p + 0.045 · les balises s’allument à l’approche

Détail que j'aime bien : la timeline n'est pas codée en dur, elle est lue dans le DOM. Chaque section porte data-seg et un data-weight (sa durée relative) - pour rallonger l'étape « projets », je change un attribut HTML, pas le moteur :

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 breathing

Deux garde-fous là-dessus, ajoutés après les premiers tests. D'abord une courbe de « dwell » : dans chaque segment, la caméra parcourt les 38 premiers pourcents, se fige au centre, puis repart - un petit coup de molette pendant qu'une carte est affichée ne fait presque rien bouger. Ensuite un snap au repos : si le scroll s'arrête au milieu d'un segment, on glisse doucement vers son centre pour cadrer l'étape proprement. C'est un verrou souple - le moindre nouvel input de scroll reprend la main :

    // 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 });
        }
      }
    }
05

Des sections qui ne défilent pas

Reste le texte. Si les sections restaient dans le flux, on les verrait défiler - retour à la case départ. Alors en mode cinéma elles passent toutes en position: fixed, empilées au même endroit, et c'est le progress qui décide laquelle existe : chacune a une fenêtre [début, fin] sur la timeline, avec un fondu d'entrée et de sortie en smoothstep.

Démo - ça tourne en vraitrois fenêtres, un seul écran

Section A

fenêtre 0.00 → 0.40

Section B

fenêtre 0.30 → 0.70

Section C

fenêtre 0.60 → 1.00

Pas de transitions CSS, pas de lib d'animation : opacité et transform sont recalculés chaque frame depuis p. Et le point crucial pour l'accessibilité et le SEO : ce CSS n'existe qu'en mode cinéma. Sans JS, sans WebGL ou avec prefers-reduced-motion, les mêmes sections s'empilent en page normale.

  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;
    }
  }
06

Le scroll a une vitesse

La position ne dit pas tout : Lenis expose aussi la vélocité, signée. Je la lisse en deux « énergies » - une pour la descente, une pour la remontée - et je les branche sur la physique de la ceinture d'astéroïdes. Descendre agite les rochers jusqu'à la collision (rebond élastique + étincelles) ; remonter les aspire dans un vortex qui les ramène en formation.

Démo - ça tourne en vraiscrollez vite vers le bas, puis remontez - n’importe où sur la page

v = 0 px/frame → énergie descente 0.00 · énergie remontée 0.00

Le vrai code du moteur - le lissage des énergies, puis la boucle de collisions (limitée aux rochers proches de la caméra, sinon le O(n²) pique) :

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);
          }
        }
      }
    }
07

Dessiner au scroll

Mon attribut SVG préféré : pathLength="1". Il déclare que le chemin mesure 1, peu importe sa vraie longueur. Du coup stroke-dasharray: 1 + un stroke-dashoffset qui va de 1 à 0, et n'importe quel tracé se dessine - pas de getTotalLength(), pas de calcul, le même CSS pour tous les chemins. C'est ce qui anime le schéma réseau de nova-infra et le blueprint du portfolio.

Démo - ça tourne en vraiScrollez, regardez

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;
  }
08

Ranger des fichiers

La scène de file-organizer, c'est un FLIP à l'envers. Je laisse d'abord la grille CSS ranger les puces (l'état FINAL), je mesure ces positions, je fige la hauteur du plateau, et seulement là je les passe en absolu sur des positions éparpillées. Le scroll interpole ensuite chaque puce vers sa place. Le chaos vient d'un PRNG seedé : même bazar à chaque visite, ce qui est plus honnête qu'un Math.random() qui changerait dans votre dos.

Démo - ça tourne en vraiScrollez, regardez

Images

photo.jpglogo.pngscan.webp

Code

main.rslib.rsCargo.toml

Petit aveu : mon premier bug ici, c'était le resize - je re-mesurais les cibles sans libérer la hauteur figée du plateau. Les puces visaient des positions d'un layout qui n'existait plus. D'où le board.style.height = '' en première ligne de 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);
    };
09

Le reste, en bref

Le reste du site est volontairement classique, et c'est ce qui permet à l'animation d'exister. Trois portes s'ouvrent avant le mode cinéma - reduced-motion, création du renderer WebGL, et seulement ensuite la classe html.cinema. Si une seule reste fermée, vous avez une page statique complète, avec exactement le même contenu. Côté budget : DPR plafonné à 2, moitié moins de particules sur mobile, boucle en pause quand l'onglet est caché, et Three.js (528 Ko quand même) en import dynamique - il ne se télécharge que si le voyage va vraiment démarrer.

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);

Bilingue : le français vit à la racine, l'anglais sous /en/, et chaque route anglaise fait quatre lignes parce que toute la logique est dans des vues partagées. Les dictionnaires sont typés - une clé oubliée en anglais, et le build refuse.

astro.config.mjs

  i18n: {
    defaultLocale: 'fr',
    locales: ['fr', 'en'],
    routing: {
      prefixDefaultLocale: false,
    },
  },

src/pages/en/index.astro

---
import HomeView from '../../views/HomeView.astro';
---

<HomeView />

Le design system fournit les tokens (var(--nds-…)) et les mixins typo Sass, branchés via un loadPath - le site n'écrit jamais une couleur en dur, y compris dans les scènes 3D. Déploiement : astro build sort un dist/ statique, un runner Gitea Actions sur mon VPS OVH le build et le publie derrière Apache et 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],
        },
      },
    },
  },
10

Refaites-le

Si je devais tout refaire demain, voilà l'ordre - c'est presque celui que j'ai suivi, en enlevant les détours :

  1. La page statique d'abord. Toutes les sections, empilées, finies, belles. C'est votre fallback, votre SEO, et votre filet de sécurité pour la suite.
  2. Lenis en singleton (chapitre 02). Copiez smooth.ts, il est fait pour ça.
  3. Le nombre magique : affichez le progress de la page dans un coin (chapitre 01). Tant que ce nombre ne bouge pas proprement, rien ne bougera proprement.
  4. La caméra (chapitre 04) : les points de contrôle, la Catmull-Rom, position = courbe(p). Regardez-la voler dans le vide avant d'ajouter quoi que ce soit.
  5. Les overlays (chapitre 05) : passez les sections en fixed derrière une classe html.cinema, jamais par défaut.
  6. La matière : étoiles, nébuleuses, ceinture. Puis la vélocité (chapitre 06) - c'est elle qui rend le scroll vivant.
  7. scrub.ts (chapitre 03) et une scène épinglée par projet. Le fallback statique se code EN MÊME TEMPS que la scène, pas après.
  8. SEO, i18n, CI (chapitre 09). Ennuyeux, indispensable.
  9. Testez sur un vrai trackpad, un vrai téléphone, et ajustez les data-weight. Les miens ont bougé cinq fois.

Et si un truc reste flou : tout le code est sur mon Gitea, et chaque snippet de cette page en est extrait au build - ce que vous lisez ici est forcément à jour.