Retour à la Knowledge Base
Breakthrough

ghost-dom-travelling-transition

Avril 2026·Projet : Ayari Racing (ayari.racing)
ReactFramer MotionTypeScriptTailwind CSS

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 :

  1. 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.
  2. Micro-gap de synchronisation — Utiliser deux moteurs d'animation indépendants (Framer Motion animate() + Web Animations API element.animate()) crée un décalage de 50-100px car ils ne démarrent pas au même frame.
  3. Gap asymétrique de scrollbar — window.innerWidth inclut la scrollbar verticale (~17px sur Windows), mais le contenu CSS fait clientWidth. 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: fixed avec top: -scrollY pour apparaître à la bonne position même si l'utilisateur a scrollé.
  • Penser à overflow-x: clip sur le conteneur parent pour éviter la scrollbar horizontale pendant l'animation.
  • requestAnimationFrame double (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 un setLocale() direct sans animation.

Dernière mise à jour : avril 2026