Redesign 2026 : la stack, et les pièges qu'elle m'a tendus
Le site a été refait cet été autour d'une seule idée : mon monogramme se construit au scroll, d'un nuage de points jusqu'au béton fini, et chaque étape de rendu raconte un chapitre de mon parcours.
Voici ce que ça utilise, et surtout les quatre endroits où ça a failli me coûter des heures.
La stack, en une ligne chacun
| Brique | Version | Pourquoi |
|---|---|---|
| Docusaurus | 3.7 | Le blog, les quatre langues et le déploiement existaient déjà. Aucune raison de tout remigrer. |
| Three.js | 0.180 | En impératif dans un useEffect. Pas de React Three Fiber. |
| GSAP + ScrollTrigger | 3.15 | Une timeline unique, lue par la position de scroll. |
| Lenis | 1.3 | Le scroll lissé, branché sur l'horloge de GSAP. |
| gltf-transform | 4.4 | Le monogramme est passé de 5,6 Mo à 312 Ko. |
Pas de React Three Fiber, volontairement. La version compatible React 19
exigeait un react@19.2.8 exact quand le projet était en 19.1. Pour un seul objet
piloté au scroll, R3F n'apportait rien qu'un useEffect ne fasse déjà, et il
coûtait un conflit de dépendances. Du Three.js impératif, avec un dispose()
sérieux au démontage — Docusaurus remonte le composant à chaque navigation client.
Le SSR de Docusaurus ne pardonne pas
Docusaurus rend le site au build, dans Node. Il n'y a ni window ni document.
Tout ce qui touche à Three.js est donc derrière <BrowserOnly>, et la scène
elle-même est chargée en React.lazy :
const Scene = React.lazy(() => import('./Scene'));
<BrowserOnly fallback={<div className={styles.canvasMount} />}>
{() => (
<Suspense fallback={<div className={styles.canvasMount} />}>
<Scene modelUrl={modelUrl} /* … */ />
</Suspense>
)}
</BrowserOnly>
React.lazy ne déclenche l'import qu'au rendu, et le seul rendu est à l'intérieur
de BrowserOnly : le module n'est jamais évalué au build. Effet de bord agréable,
mesuré sur le bundle final : Three.js, GSAP et Lenis pèsent 0 octet dans
main.js. Ils vivent dans un chunk chargé à la demande.
Le sweep : révéler, pas fondre
Les cinq étapes — points, arêtes, faces plates, ombré, béton — sortent toutes de la
même BufferGeometry. Ce sont des couches empilées dont on pilote l'opacité,
pas cinq modèles.
La révélation ne fond pas globalement : elle balaie l'objet. Un patch via
onBeforeCompile compare la hauteur du vertex à un uniform uProgress :
float sweepH = abs(uInvert - vSweepH);
float sweepDelta = uProgress - sweepH;
if (sweepDelta < 0.0) discard;
uInvert vaut 0 ou 1 et retourne l'axe sans branchement, ce qui fait alterner le
sens : l'étape 1 monte, la 2 descend, la 3 monte. Jamais deux fois le même geste.
Piège n°1 — le token d'injection
Le point d'injection évident est #include <dithering_fragment>. Il n'existe pas
dans le shader des points. Et un String.replace qui ne trouve rien ne lève
aucune erreur : j'aurais perdu toute la couche de points en silence.
Le token présent dans les quatre types de matériaux que j'utilise est
#include <opaque_fragment>. Il y a maintenant une assertion qui throw en
développement si une montée de version de Three renomme le chunk.
Piège n°2 — la bande d'accent qui ne part jamais
Une bande vive suit le front du balayage. Mais uProgress reste à 1 au repos :
tous les points dont la hauteur finale est proche du sommet restaient teintés en
rouge en permanence. Une barre rouge garée en haut du logo. Un smoothstep qui
éteint la bande quand le front arrive a réglé ça.
Piège n°3 — trois maillages, une géométrie
Les étapes 3, 4 et 5 sont trois matériaux sur la même géométrie, à la même
position. Pendant un fondu croisé, c'est du z-fighting garanti. polygonOffset
plus renderOrder les sépare en profondeur.
Piège n°4 — l'ombre qui ne suit pas le balayage
La passe de profondeur utilise un shader différent, qui ne porte pas le sweep : une
couche à moitié construite projetait une ombre entière. Le castShadow n'est donc
activé qu'une fois le balayage terminé.
Une seule horloge
Lenis et la boucle de rendu partagent gsap.ticker. Un seul requestAnimationFrame
pour toute la page, et un démontage qui retire les deux callbacks :
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.add(renderTick);
gsap.ticker.lagSmoothing(0);
L'épinglage se fait en position: sticky en CSS, pas avec le pin de
ScrollTrigger : celui-ci clone et enveloppe le DOM, ce qui se marie mal avec le
layout de Docusaurus. ScrollTrigger ne fait que lire la progression.
La navbar : trois diagnostics, dont deux faux
Je voulais une navbar transparente au-dessus du hero noir. Elle restait obstinément crème.
Premier diagnostic : la classe n'est pas appliquée. Faux. Deuxième : la règle
CSS est absente du fichier compilé. Faux aussi — mon grep ne correspondait
pas à la forme minifiée. La vraie cause :
.navbar--fixed-top { position: sticky; top: 0 }
La barre est dans le flux. Elle occupe sa propre bande et le hero commence en
dessous. « Transparent » ne révélait donc pas le noir du hero, mais le fond crème
de la page. Il n'y avait rien de sombre derrière quoi être transparent. Corrigé
en position: fixed avec le wrapper remonté d'autant.
Au passage : Docusaurus rend son propre <body class> et ignore le className
de Helmet. L'état s'accroche maintenant à un marqueur dans le hero, via
body:has(.oh-hero-root) — présent dès le rendu serveur, donc sans clignotement.
Le mobile fait la même chose, autrement
Le téléphone ne reçoit pas une version dégradée. La séquence complète tourne, seule la composition change : l'objet se centre et monte, le texte prend toute la largeur en dessous, et la caméra recule uniquement de ce que la largeur exige — sinon un objet d'une unité de large ne rentre pas dans un viewport étroit. Il occupe 72 % de la largeur sur un iPhone contre 35 % sur desktop.
Côté charge : 4 000 points au lieu de 9 000, ombres coupées, pixelRatio plafonné
à 1,5.
Seul prefers-reduced-motion bascule sur la version statique — qui reste rendue
côté serveur, avec les cinq chapitres en vrai HTML.
Ce que je referais pareil
Trois choses ont vraiment payé.
Coder les invariants en assertions. Le sweep aurait pu casser en silence à chaque montée de Three.js. Il lève maintenant une erreur en développement.
Calculer les contrastes au lieu de les estimer. La navbar défilée était à un alpha de 0,82. Le calcul a montré que l'accent au survol tombait à 2,57:1 sur un fond clair — échec AA. À 0,93, le pire cas tient à 4,5:1. Ça ne se voit pas à l'œil, ça se voit dans les chiffres.
Une seule garde partagée. La règle « ce client peut-il faire du WebGL » était copiée dans trois composants. En ouvrant le mobile sur le hero, j'ai oublié les deux autres : le pied de page 3D ne se rendait plus sur téléphone. Un seul hook, plus rien à désynchroniser.
Le code du site est ouvert, celui du monogramme non — c'est un fichier .glb de
312 Ko et le shader tient en quinze lignes. Tout est reproductible.
