Vécu projet : Ayari Racing (ayari.racing) Date : Avril 2026 Stack : React 18, Framer Motion 12, TypeScript, Tailwind CSS
Contexte
SPA bilingue (FR/EN) avec changement de locale via un contexte React. On veut un effet de travelling horizontal type "switch de bureau Windows" : le contenu FR sort d'un côté et le contenu EN entre de l'autre, côte à côte, sans aucun état intermédiaire visible.
Problème
Trois obstacles rencontrés par itérations successives :
- Fond vide entre les panneaux — Avec un seul contenu React animé, quand il sort de l'écran, le fond brut apparaît. Un overlay teinté ne fait que cacher le problème.
- Micro-gap de synchronisation — Utiliser deux moteurs d'animation indépendants (Framer Motion
animate()+ Web Animations APIelement.animate()) crée un décalage de 50-100px car ils ne démarrent pas au même frame. - Gap asymétrique de scrollbar —
window.innerWidthinclut la scrollbar verticale (~17px sur Windows), mais le contenu CSS faitclientWidth. Le gap n'apparaît que dans un sens de navigation.
Solution
Architecture en 3 couches :
1. Ghost DOM — cloneNode(true) capture un snapshot figé du contenu actuel dans un élément détaché du React tree. Le vrai contenu se repositionne hors-écran, React re-render la nouvelle locale, puis les deux glissent ensemble.
2. Single Motion Source — Une seule motionValue Framer Motion pilote les deux éléments. Le ghost se positionne via un listener x.on('change') avec la relation ghostX = x - direction * viewportWidth. Même source = zéro désync, adjacence au pixel près.
3. clientWidth — document.documentElement.clientWidth au lieu de window.innerWidth pour exclure la scrollbar du calcul de distance.
// Concept simplifié
const ghost = wrapper.cloneNode(true); // 1. Snapshot
x.set(dir * vw); // Park nouveau contenu hors-écran
setLocale(newLocale); // React re-render hors-écran
const unsub = x.on('change', (v) => { // 2. Single source
ghost.style.transform = `translateX(${v - dir * vw}px)`;
});
await animate(x, 0, { duration, ease }); // Les deux glissent ensemble
unsub(); ghost.remove(); // 3. Cleanup
Pourquoi
- Ghost DOM résout l'impossibilité de rendre deux locales simultanément (un seul contexte i18n). Le clone fige l'état visuel sans React.
- Single Motion Source élimine le problème fondamental de synchronisation multi-moteurs. La relation mathématique garantit l'adjacence à chaque frame.
- clientWidth est la seule mesure fiable de la largeur de contenu réelle sur toutes les plateformes (scrollbar overlay macOS vs scrollbar classique Windows).
- Ce pattern est réutilisable pour tout "travelling" entre deux états de contenu : theme switcher, tabs animées, wizard steps.
Points d'attention
- Le ghost est un clone DOM brut — il ne réagit pas aux événements React. Toujours
pointer-events: none. - Le ghost doit être en
position: fixedavectop: -scrollYpour apparaître à la bonne position même si l'utilisateur a scrollé. - Penser à
overflow-x: clipsur le conteneur parent pour éviter la scrollbar horizontale pendant l'animation. requestAnimationFramedouble (rAF dans rAF) est nécessaire pour que React ait fini de peindre le nouveau contenu avant de lancer le slide.- Respecter
prefers-reduced-motion— fallback sur unsetLocale()direct sans animation.
Dernière mise à jour : avril 2026