ITSkillsCenter
Business Digital

Animer en R3F : useFrame, react-spring/three et Theatre.js

14 min de lecture

📍 Article principal de la série : Three.js, React Three Fiber et WebGPU en 2026 : 3D temps réel sur le web

Introduction

Animer une scène 3D, c’est choisir entre trois grandes familles d’outils : la boucle manuelle useFrame pour les contrôles bas niveau, react-spring/three pour les transitions physiques déclaratives, et Theatre.js pour les animations cinématiques scénarisées avec un éditeur visuel. Chaque approche a son domaine de pertinence, et les confondre conduit soit à un code illisible, soit à des animations rigides. Ce tutoriel construit le même mouvement avec les trois approches pour les comparer concrètement, puis donne la grille de décision pour choisir dans son propre projet.

Prérequis

  • Un projet R3F v9 fonctionnel
  • Notions de base sur les hooks React et les refs
  • Compréhension des concepts d’easing et de spring damping
  • Temps estimé : 45 à 60 minutes

Étape 1 — Le baseline : useFrame manuel

Le hook useFrame de R3F est le plus bas niveau. Il s’exécute une fois par frame avant le rendu, reçoit l’état R3F et le delta temps depuis la frame précédente. C’est l’outil universel : tout ce qu’on fait avec react-spring ou Theatre.js peut s’écrire avec useFrame, c’est juste plus verbeux pour les cas courants.

// src/components/AnimatedBoxFrame.tsx
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import type { Mesh } from 'three'

export default function AnimatedBoxFrame() {
  const ref = useRef<Mesh>(null)
  const start = useRef(performance.now())

  useFrame(() => {
    if (!ref.current) return
    const t = (performance.now() - start.current) / 1000
    // monter de 0 à 1 sur 1 seconde puis rester
    ref.current.position.y = Math.min(1, t)
  })

  return (
    <mesh ref={ref}>
      <boxGeometry args={[0.5, 0.5, 0.5]} />
      <meshStandardMaterial color="#ff6b00" />
    </mesh>
  )
}

L’animation est linéaire et abrupte à l’arrivée : le cube monte à vitesse constante pendant une seconde puis s’arrête net. Pour adoucir, on remplace le clamp par un easing — par exemple position.y = 1 - Math.pow(1 - Math.min(1, t), 3) pour un ease-out cubique. C’est cette flexibilité bas niveau qui rend useFrame indispensable : aucune autre solution ne donne autant de contrôle sur la courbe exacte de l’animation.

Étape 2 — react-spring/three : les transitions physiques

react-spring modélise les animations comme des systèmes physiques : ressort, masse, friction. Au lieu de spécifier une durée et une easing curve, on décrit le comportement physique souhaité (raideur, amortissement) et la bibliothèque calcule la trajectoire. Le résultat est naturellement organique et s’enchaîne bien quand on change de cible en cours d’animation.

npm install @react-spring/three

Une fois installé, on l’utilise via le hook useSpring et le composant animated. Les valeurs animées sont des signaux qu’on passe en props ; react-spring met à jour les Three.js objects sans déclencher de re-render React, ce qui est essentiel à 60 fps.

// src/components/AnimatedBoxSpring.tsx
import { useSpring, animated } from '@react-spring/three'
import { useState } from 'react'

export default function AnimatedBoxSpring() {
  const [up, setUp] = useState(false)
  const { position } = useSpring({
    position: up ? [0, 1, 0] : [0, 0, 0],
    config: { tension: 180, friction: 14 },
  }) as any

  return (
    <animated.mesh position={position} onClick={() => setUp((v) => !v)}>
      <boxGeometry args={[0.5, 0.5, 0.5]} />
      <meshStandardMaterial color="#ff6b00" />
    </animated.mesh>
  )
}

Au clic sur le cube, il monte d’une unité avec un léger overshoot dû au comportement de ressort. Cliquer à nouveau le ramène à zéro, et si on clique pendant une animation en cours, le cube change de cible immédiatement sans saut visuel. Cette continuité est le grand intérêt de react-spring pour les UIs interactives. Les paramètres tension et friction sont les seules manettes : tension haute pour un ressort vif, friction haute pour amortir l’overshoot. Quelques presets existent (config.gentle, config.wobbly, config.stiff, config.slow, config.molasses) pour démarrer rapidement.

Étape 3 — Theatre.js : l’animation scénarisée

Pour des séquences cinématiques avec plusieurs objets coordonnés (caméra qui voyage, plusieurs éléments qui apparaissent en cascade, parallax piloté au scroll), Theatre.js apporte un éditeur visuel directement intégré au navigateur. On compose ses keyframes à la souris dans un panneau qui flotte par-dessus la scène, on exporte le JSON résultant, et on l’embarque dans le bundle de production.

npm install @theatre/core @theatre/studio @theatre/r3f

L’installation comprend trois packages. @theatre/core est le runtime exécuté en production, léger. @theatre/studio est l’éditeur, qu’on n’inclut qu’en mode dev. @theatre/r3f est l’extension qui permet de cibler les objets R3F par leurs noms dans l’éditeur.

// src/scenes/CinematicScene.tsx
import { Canvas } from '@react-three/fiber'
import { SheetProvider, editable as e } from '@theatre/r3f'
import { getProject } from '@theatre/core'
import studio from '@theatre/studio'
import projectState from './state.json' // exporté depuis l'éditeur

if (import.meta.env.DEV) studio.initialize()

const project = getProject('Demo', { state: projectState })
const sheet = project.sheet('Scene1')

export default function CinematicScene() {
  return (
    <Canvas>
      <SheetProvider sheet={sheet}>
        <e.mesh theatreKey="Box">
          <boxGeometry args={[0.5, 0.5, 0.5]} />
          <meshStandardMaterial color="#ff6b00" />
        </e.mesh>
        <ambientLight intensity={0.5} />
        <directionalLight position={[3, 3, 3]} />
      </SheetProvider>
    </Canvas>
  )
}

Le composant e.mesh est une variante de mesh qui s’inscrit auprès de Theatre.js sous le nom indiqué dans theatreKey. Une fois la page chargée en mode dev, l’éditeur Theatre.js s’ouvre en bas de l’écran ; on y voit l’objet « Box », on lui ajoute des propriétés (position, rotation), on pose des keyframes en parcourant la timeline. Une fois satisfait, on clique sur Export pour télécharger le JSON d’état, qu’on remplace dans state.json. La séquence est désormais figée et rejouable en production sans l’éditeur.

Étape 4 — Synchroniser une animation Theatre.js avec le scroll

L’usage le plus courant de Theatre.js sur des sites marketing est le scroll-driven : la timeline avance au rythme du scroll de l’utilisateur, créant des effets de parallax 3D ou des séquences déclenchées au passage de sections. C’est le pattern derrière des sites comme Apple AirPods Pro ou Bruno Simon’s portfolio.

import { useEffect } from 'react'

useEffect(() => {
  const onScroll = () => {
    const ratio = window.scrollY / (document.body.scrollHeight - window.innerHeight)
    sheet.sequence.position = ratio * sheet.sequence.length
  }
  window.addEventListener('scroll', onScroll, { passive: true })
  return () => window.removeEventListener('scroll', onScroll)
}, [])

On lit la position de scroll relative (de 0 à 1), on la multiplie par la durée totale de la séquence, et on assigne la valeur à sheet.sequence.position. Theatre.js interpole les keyframes correspondantes et met à jour les objets R3F. L’option passive: true sur l’event listener garantit que le navigateur peut continuer son scrolling smooth ; sans elle, Chrome affiche un avertissement et le scroll devient saccadé.

Étape 5 — Combiner les trois approches dans une même scène

En production, on n’utilise pas un outil exclusivement. Le pattern courant est : Theatre.js pour la grande chorégraphie (caméra, transitions de section), react-spring pour les micro-interactions (hover, click feedback), useFrame pour les boucles continues (rotation lente d’un logo, ondulation d’un océan). Les trois cohabitent sans conflit parce qu’ils opèrent sur des propriétés différentes ou des objets différents.

// Dans une scène complexe :
// - La caméra suit la timeline Theatre.js (scroll-driven)
// - Un produit central tourne en boucle via useFrame
// - Les boutons d'UI 3D ressentent les hovers via react-spring

L’enjeu d’une scène avec trois systèmes d’animation est l’ordre d’exécution. useFrame avec une priorité supérieure à zéro s’exécute après les mises à jour Theatre.js et react-spring ; on l’utilise pour appliquer les contraintes finales (look-at, alignement, IK simple). Si l’ordre n’est pas correct, on observe un retard d’une frame entre les systèmes, visible en zoom au ralenti mais imperceptible à vitesse normale dans la majorité des cas.

Étape 6 — Choisir entre les trois : grille de décision

La règle pratique tient en cinq lignes. Si la durée et la courbe sont fixes et déterminées au design, react-spring ou useFrame avec une easing curve. Si l’utilisateur change la cible pendant l’animation (drag-and-drop, hover qui change), react-spring sans hésitation pour la continuité. Si plusieurs objets doivent bouger en synchronisation avec une timeline éditable visuellement, Theatre.js. Si on a besoin du bas niveau absolu (intégration physique custom, contraintes mathématiques), useFrame. Si on est junior et qu’on veut quelque chose qui rend bien sans réfléchir, react-spring presets.

Étape 7 — Performance : éviter les re-renders inutiles

Le piège classique de l’animation R3F est de stocker la valeur animée en useState. Chaque mise à jour déclenche un re-render React qui re-traverse l’arbre, alors qu’on voudrait juste muter une propriété Three.js. La règle est de ne jamais utiliser setState dans une boucle d’animation continue.

// ❌ Anti-pattern
function BadAnim() {
  const [y, setY] = useState(0)
  useFrame(() => setY((v) => v + 0.01)) // re-render à chaque frame
  return <mesh position={[0, y, 0]} />
}

// ✅ Pattern correct
function GoodAnim() {
  const ref = useRef<Mesh>(null)
  useFrame(() => {
    if (ref.current) ref.current.position.y += 0.01
  })
  return <mesh ref={ref} />
}

La différence de performance est massive : la version anti-pattern peut consommer 5 à 10 ms par frame en pure surcharge React quand la scène devient complexe, alors que la version correcte mute directement l’objet sans déclencher React. Sur 16,6 ms de budget par frame à 60 fps, c’est l’écart entre fluide et saccadé. react-spring et Theatre.js gèrent ce problème en interne : leurs animations passent par des refs internes et n’invoquent jamais setState pour les valeurs animées.

Étape 8 — Animer une caméra qui suit un objet

Un cas concret qu’on rencontre dans presque tout projet 3D web est la caméra qui suit un objet sans rigidement coller dessus : un effet de poursuite avec léger retard, qui rend la caméra vivante. On combine ici useFrame pour la boucle continue et un facteur d’interpolation pour adoucir le suivi.

// src/hooks/useSmoothFollow.ts
import { useFrame, useThree } from '@react-three/fiber'
import { Vector3 } from 'three'
import { useRef } from 'react'
import type { Object3D } from 'three'

export function useSmoothFollow(target: React.RefObject<Object3D>, offset = new Vector3(0, 2, 5), smoothing = 4) {
  const camera = useThree((s) => s.camera)
  const desired = useRef(new Vector3())

  useFrame((_, delta) => {
    if (!target.current) return
    desired.current.copy(target.current.position).add(offset)
    // lerp factor exponentiel indépendant du framerate
    const f = 1 - Math.exp(-smoothing * delta)
    camera.position.lerp(desired.current, f)
    camera.lookAt(target.current.position)
  })
}

Le calcul du facteur de lerp via une exponentielle décroissante est une astuce essentielle : avec un simple lerp(camera.position, desired, 0.1), le comportement dépendrait du framerate (deux fois plus rapide à 60 fps qu’à 30 fps). La forme 1 - exp(-k * delta) rend le suivi indépendant du framerate, ce qui est crucial pour une expérience cohérente sur des devices hétérogènes. On ajuste smoothing entre 2 (caméra paresseuse, retard visible) et 10 (caméra réactive, presque rigide). Cette technique d’interpolation framerate-independent est connue sous le nom de damped exponential et s’applique à toute valeur qu’on veut faire tendre vers une cible avec amortissement, pas seulement aux positions de caméra.

Erreurs fréquentes

Erreur Cause Solution
Animation saccadée à 30 fps setState dans useFrame Utiliser des refs et muter les propriétés Three.js
react-spring ne s’anime pas Composant non wrappé en animated. Importer et utiliser animated.mesh au lieu de mesh
Studio Theatre.js inclus en production Pas de check import.meta.env.DEV Conditionner l’import dynamique du studio
Animation Theatre.js désynchronisée du scroll Listener non passive Ajouter { passive: true } à addEventListener
Spring qui ne s’arrête jamais Friction trop basse Augmenter friction ou définir precision

Étape 9 — Synchroniser plusieurs animations entre elles

Quand une scène contient plusieurs objets qui doivent réagir ensemble — par exemple une lumière qui suit un personnage, une caméra qui ajuste son cadrage, une UI 3D qui affiche les stats du personnage — on a besoin de coordination. Trois patterns émergent. Le partage d’un état global via un store Zustand auquel tous les hooks d’animation s’abonnent est l’approche la plus simple et la plus performante. La signalisation par event emitter (Three.js fournit un EventDispatcher) reste utile pour des événements ponctuels rares, mais coûteuse pour des mises à jour à 60 fps. Enfin, le partage de refs entre composants frères via un contexte React permet une coordination directe sans passer par un store, mais limite la composition. Pour la majorité des projets, Zustand avec une store dédiée à la scène 3D donne la meilleure combinaison performance/lisibilité, et c’est ce que la documentation pmndrs recommande explicitement.

Tutoriels frères

Lectures complémentaires

FAQ

Peut-on utiliser GSAP avec R3F ?
Oui. GSAP fonctionne très bien sur les objets Three.js : gsap.to(mesh.position, { y: 1, duration: 1 }). Pour les équipes déjà familières avec GSAP, c’est une option valide. L’inconvénient est qu’on perd l’intégration React (déclaratif, refs typées) et qu’il faut gérer le cleanup manuellement avec tween.kill().

react-spring ou framer-motion-3d ?
framer-motion-3d est très proche conceptuellement et reste valide. react-spring a un écosystème plus mature côté Three.js et un bundle plus léger. Sur un projet existant qui utilise déjà framer-motion en 2D, framer-motion-3d est plus cohérent.

Comment debugger une animation Theatre.js qui ne joue pas ?
Vérifier que le SheetProvider est bien parent des composants e.mesh, que le theatreKey est unique, et que la timeline a au moins deux keyframes (sinon il n’y a rien à interpoler). Le studio en mode dev affiche les erreurs dans la console.

Faut-il préférer easings.net ou les configs react-spring ?
Pour une animation déterministe et pixelée (UI strict, charte graphique précise), une easing curve d’easings.net est plus reproductible. Pour une sensation organique et tolérante (jeu, expérience marketing), react-spring est plus naturel.

Comment animer un shader TSL ?
Soit en exposant une uniform qu’on mute via useFrame, soit en utilisant directement time dans le graphe TSL pour les animations dépendant du temps. Pour une transition pilotée (start/stop, change of direction), uniform + react-spring est la combinaison la plus puissante.

Peut-on enchaîner plusieurs animations Theatre.js ?
Oui via plusieurs sheets dans un même projet, chacune représentant une scène ou une séquence. On joue les sheets séparément ou en chaîne via leur API play(). C’est la manière propre de structurer un site avec plusieurs sections animées indépendamment.

Comment gérer la pause d’une animation quand l’onglet est en arrière-plan ?
R3F détecte automatiquement le visibilitychange et suspend la boucle de rendu quand l’onglet est masqué. C’est le comportement souhaité dans 99% des cas et il n’y a rien à configurer. Pour des cas particuliers (animations qui doivent continuer en arrière-plan, par exemple une simulation longue), on désactive ce comportement avec frameloop="always" sur le Canvas, mais c’est rarement justifié.

Animer un objet à travers plusieurs scènes 3D, comment faire ?
La pratique propre est d’avoir une seule scène R3F pour toute l’application et de naviguer dans cette scène en bougeant la caméra et en faisant apparaître/disparaître des sous-arbres. Charger plusieurs Canvas et synchroniser leurs animations entre eux est techniquement possible mais introduit des problèmes de performance et de cohérence visuelle qui ne valent pas la complexité.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité