Aller au contenu principal

Redesign 2026 : la stack, et les pièges qu'elle m'a tendus

· 6 minutes de lecture
Odran
CEO @ AI Smarttalk

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

BriqueVersionPourquoi
Docusaurus3.7Le blog, les quatre langues et le déploiement existaient déjà. Aucune raison de tout remigrer.
Three.js0.180En impératif dans un useEffect. Pas de React Three Fiber.
GSAP + ScrollTrigger3.15Une timeline unique, lue par la position de scroll.
Lenis1.3Le scroll lissé, branché sur l'horloge de GSAP.
gltf-transform4.4Le 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.