React Three Fiber Animation: When to Use useFrame, GSAP, or Springs

Choose between useFrame, GSAP, and React Spring for R3F animations. Compare render loops, timelines, spring motion, and production trade-offs.

R3F animation cover
Vidushi Saxena

Vidushi Saxena

Developer

Read Time: 9 mins

Category: Engineering

Share this Article:

React Three Fiber gives you several credible ways to animate a scene. The difficult part is deciding who owns time.

Use useFrame when motion is continuous and belongs to each rendered frame. Use GSAP when the interaction depends on authored sequencing, easing, and playhead control. Use @react-spring/three when application state defines a changing destination and the animation should fluidly follow it.

Interaction jobDefault owner
Continuous rotation, camera following, shader updatesuseFrame
Designed entrance, multi-step choreography, replay/reverseGSAP
Hover, selection, or changing state targetsReact Spring
GLTF-authored clipsR3F/Drei animation APIs instead

The useful question is who should own time for this property?

A spinning model, a six-beat product reveal, and a hover scale can all appear inside the same <Canvas>. They still represent different timing problems. Treating them as one problem usually produces either an overbuilt render-loop callback or several animation systems competing over the same transforms.

A useful architecture is:

React intent → animation owner → Three.js object → R3F render

React owns durable application intent. The animation system decides how transient values move through time. Three.js objects receive those values. React Three Fiber renders the scene.


R3F already owns scene rendering

React Three Fiber is a React renderer for Three.js. It does not require React state to carry every intermediate transform in an animation.

R3F already owns scene

Its useFrame hook executes a callback inside the shared render loop just before a frame is rendered. The hook also receives the frame delta, expressed in seconds. R3F’s documentation explicitly cautions against calling React setState inside that hot path.

A basic rotating mesh is enough to see the boundary:

JSX
import { useRef } from "react";import { useFrame } from "@react-three/fiber";import type { Mesh } from "three"; export function Spinner() {  const mesh = useRef<Mesh>(null);   useFrame((_, delta) => {    if (!mesh.current) return;     mesh.current.rotation.y += delta * 0.8;  });   return (    <mesh ref={mesh}>      <boxGeometry />      <meshStandardMaterial />    </mesh>  );}

React declares the mesh, geometry and material. R3F advances the frame callback. The callback mutates a transient scene value directly.

React state should describe intent, not transport every frame

Suppose the model can be selected:

JSX
const [selected, setSelected] = useState(false);

That is durable application state. Other components may care about it. The value may change content, controls, analytics or accessibility behavior.

This is a different category:

JSX
useFrame(() => {  setRotation((rotation) => rotation + 0.01);});

Now React is carrying a high-frequency animation value through component state. R3F’s guidance instead puts transient frame updates in useFrame, generally through refs and direct object mutation.

React can decide what the interaction means. It does not need to narrate every intermediate transform.


useFrame: when motion belongs to the frame

useFrame is the clearest default when a value is procedural rather than authored.

A camera easing toward pointer input is one example. A shader timer is another. Continuous model rotation, damping, particle updates and simulation-style behavior fit the same class.

Consider pointer following:

JSX
import { useRef } from "react";import { useFrame, useThree } from "@react-three/fiber";import type { Mesh } from "three"; export function PointerFollower() {  const mesh = useRef<Mesh>(null);  const pointer = useThree((state) => state.pointer);   useFrame((_, delta) => {    if (!mesh.current) return;     const smoothing = 1 - Math.exp(-8 * delta);    const targetX = pointer.x * 2;    const targetY = pointer.y * 1.2;     mesh.current.position.x +=      (targetX - mesh.current.position.x) * smoothing;     mesh.current.position.y +=      (targetY - mesh.current.position.y) * smoothing;  });   return (    <mesh ref={mesh}>      <sphereGeometry args={[0.6, 32, 32]} />      <meshStandardMaterial />    </mesh>  );}

R3F pointer values are normalized coordinates. Multiplying them by 2 or 1.2 here is an interaction decision, not a universal conversion to Three.js world space. The camera and viewport determine what world-space mapping makes sense for a particular scene.

The architecture is the important part: the destination changes continuously, so the motion is evaluated continuously.

A complete shader-time example

A shader timer needs more context than a dangling material.current.uniforms reference. Here is the full boundary:

JSX
import { useMemo, useRef } from "react";import { useFrame } from "@react-three/fiber";import {  ShaderMaterial,  type IUniform,} from "three"; const vertexShader = `  varying vec2 vUv;   void main() {    vUv = uv;    gl_Position = projectionMatrix * modelViewMatrix      * vec4(position, 1.0);  }`; const fragmentShader = `  uniform float uTime;  varying vec2 vUv;   void main() {    float pulse = 0.5 + 0.5 * sin(uTime * 2.0);    gl_FragColor = vec4(vUv.x, pulse, vUv.y, 1.0);  }`; export function AnimatedShaderPlane() {  const material = useRef<ShaderMaterial>(null);  const elapsed = useRef(0);   const uniforms = useMemo<Record<string, IUniform>>(    () => ({      uTime: { value: 0 },    }),    []  );   useFrame((_, delta) => {    elapsed.current += delta;     if (material.current) {      material.current.uniforms.uTime.value = elapsed.current;    }  });   return (    <mesh>      <planeGeometry args={[2, 2]} />      <shaderMaterial        ref={material}        uniforms={uniforms}        vertexShader={vertexShader}        fragmentShader={fragmentShader}      />    </mesh>  );}

The timer is frame-owned. React does not need to rerender when uTime changes.

Where useFrame earns its place

Use it for work that naturally answers the question:

What should this value be on this frame?

That commonly includes continuous rotation, pointer following, damping, camera drift, shader uniforms and procedural simulation.Its advantage is architectural clarity, not an automatic performance win.

A useFrame callback can still allocate too much, run expensive calculations, keep unnecessary rendering alive or perform work better moved elsewhere.The hook gives frame work the correct home. It does not make frame work free.


GSAP: when motion belongs to a sequence

Now imagine a product hero with a designed entrance.The camera starts first. The product rotates before the camera finishes. A light intensifies. The object settles. During iteration, the sequence needs to pause, replay and reverse.The difficult part is no longer calculating a frame.It is coordinating time.

GSAP maintains an animation-frame-driven ticker that advances its global timeline, and its Timeline API supplies playhead and sequencing control for groups of tweens.

JSX
"use client"; import { useLayoutEffect, useRef } from "react";import { gsap } from "gsap";import type { Group } from "three"; export function ProductIntro() {  const group = useRef<Group>(null);   useLayoutEffect(() => {    if (!group.current) return;     const timeline = gsap.timeline();     timeline      .fromTo(        group.current.position,        { z: -3 },        {          z: 0,          duration: 1.2,          ease: "power3.out",        }      )      .fromTo(        group.current.rotation,        { y: -0.7 },        {          y: 0,          duration: 1,          ease: "power2.out",        },        "<0.2"      );     return () => {      timeline.kill();    };  }, []);   return (    <group ref={group}>      <mesh>        <boxGeometry />        <meshStandardMaterial />      </mesh>    </group>  );}

GSAP documents kill() as removing a timeline from its parent and stopping it, which makes lifecycle cleanup explicit when the React component unmounts.

The value here is not merely that GSAP can mutate a Three.js vector. The timeline owns the relationship between authored moments.

GSAP’s ticker and R3F’s renderer have different jobs

GSAP’s ticker advances GSAP animation time.R3F renders the Three.js scene.Those are different responsibilities.

Having two RAF-backed systems present does not by itself mean the WebGL scene is rendered twice. GSAP may update object values while R3F performs the actual scene render through its own frame scheduling.

The production question is: “Which system owns this property, and which system owns the scene render?”


React Spring: when motion belongs to a changing destination

Springs solve a different problem again. An object is inactive, then active. A card is unselected, then selected. A model is resting, then hovered. Before it reaches the destination, the target changes again.

React Spring is useful here because the meaningful input is the destination. The spring owns interpolation toward that target.

For React Three Fiber, the official integration is @react-spring/three. Its documentation uses animated scene elements and shows spring values updating Three.js properties without requiring a React render for each intermediate animation value.

JSX
import { useState } from "react";import {  animated,  useSpring,} from "@react-spring/three"; export function SelectableMesh() {  const [selected, setSelected] = useState(false);   const springs = useSpring({    scale: selected ? 1.2 : 1,    position: selected      ? ([0, 0.25, 0] as const)      : ([0, 0, 0] as const),  });   return (    <animated.mesh      scale={springs.scale}      position={springs.position}      onClick={() => setSelected((value) => !value)}    >      <boxGeometry />      <meshStandardMaterial />    </animated.mesh>  );}

React owns selected. The spring owns the movement toward the resulting position and scale. “Declarative” here describes the relationship between state and destination. It does not mean React becomes the frame engine.


Runnable comparison: one visual, three animation owners

The cleanest way to compare the models is to keep the visual subject constant and change the timing requirement.

The following client component creates one R3F scene with three selectable modes:

  • Frame: the mesh continuously follows pointer input using useFrame.
  • GSAP: the mesh runs a replayable and reversible entrance timeline.
  • Spring: clicking repeatedly changes a spring destination, including while the previous transition is still moving.

Only the active mode is mounted, so inactive systems cannot write to the same mesh property.

JSX
"use client"; import {  useLayoutEffect,  useRef,  useState,} from "react";import {  Canvas,  useFrame,  useThree,} from "@react-three/fiber";import {  animated,  useSpring,} from "@react-spring/three";import { gsap } from "gsap";import type { Mesh } from "three"; type Mode = "frame" | "gsap" | "spring"; export default function AnimationOwnershipDemo() {  const [mode, setMode] = useState<Mode>("frame");   return (    <div>      <div        role="group"        aria-label="Animation owner"      >        <button          type="button"          aria-pressed={mode === "frame"}          onClick={() => setMode("frame")}        >          useFrame        </button>         <button          type="button"          aria-pressed={mode === "gsap"}          onClick={() => setMode("gsap")}        >          GSAP        </button>         <button          type="button"          aria-pressed={mode === "spring"}          onClick={() => setMode("spring")}        >          Spring        </button>      </div>       <Canvas        camera={{          position: [0, 0, 5],          fov: 45,        }}      >        <ambientLight intensity={1.2} />        <directionalLight          position={[3, 4, 5]}          intensity={2}        />         {mode === "frame" && <FrameMode />}        {mode === "gsap" && <GsapMode />}        {mode === "spring" && <SpringMode />}      </Canvas>    </div>  );} function SharedGeometry() {  return (    <>      <torusKnotGeometry        args={[0.85, 0.28, 128, 24]}      />      <meshStandardMaterial        roughness={0.3}        metalness={0.15}      />    </>  );} function FrameMode() {  const mesh = useRef<Mesh>(null);  const pointer = useThree((state) => state.pointer);   useFrame((_, delta) => {    if (!mesh.current) return;     const smoothing = 1 - Math.exp(-7 * delta);     mesh.current.position.x +=      (pointer.x * 1.4 -        mesh.current.position.x) *      smoothing;     mesh.current.position.y +=      (pointer.y * 0.8 -        mesh.current.position.y) *      smoothing;     mesh.current.rotation.y += delta * 0.6;  });   return (    <mesh ref={mesh}>      <SharedGeometry />    </mesh>  );} function GsapMode() {  const mesh = useRef<Mesh>(null);  const timeline = useRef<gsap.core.Timeline | null>(    null  );   useLayoutEffect(() => {    if (!mesh.current) return;     const tl = gsap.timeline({      paused: true,    });     tl.fromTo(      mesh.current.position,      {        z: -4,        y: -0.8,      },      {        z: 0,        y: 0,        duration: 1.1,        ease: "power3.out",      }    ).fromTo(      mesh.current.rotation,      {        y: -1.2,      },      {        y: 0,        duration: 0.9,        ease: "power2.out",      },      "<0.2"    );     timeline.current = tl;    tl.play(0);     return () => {      tl.kill();      timeline.current = null;    };  }, []);   return (    <group>      <mesh ref={mesh}>        <SharedGeometry />      </mesh>       <HtmlControls        onReplay={() =>          timeline.current?.restart()        }        onReverse={() =>          timeline.current?.reverse()        }      />    </group>  );} function HtmlControls({  onReplay,  onReverse,}: {  onReplay: () => void;  onReverse: () => void;}) {  // In a production demo, place these controls  // in DOM adjacent to Canvas rather than inside  // the WebGL scene. Kept separate here to make  // timeline ownership explicit.  return null;} function SpringMode() {  const [selected, setSelected] =    useState(false);   const springs = useSpring({    scale: selected ? 1.25 : 1,    position: selected      ? ([0, 0.45, 0] as const)      : ([0, 0, 0] as const),    rotation: selected      ? ([0, Math.PI * 0.35, 0] as const)      : ([0, 0, 0] as const),  });   return (    <animated.mesh      scale={springs.scale}      position={springs.position}      rotation={springs.rotation}      onClick={() =>        setSelected((value) => !value)      }    >      <SharedGeometry />    </animated.mesh>  );}

For a published interactive demo, put GSAP replay and reverse buttons in the normal DOM beside the canvas and pass their handlers into the scene rather than hiding interface controls inside WebGL. The important architecture remains the same: changing mode unmounts the previous animation owner before the next one receives the object.

This comparison exposes what three disconnected “move from zero to one” snippets cannot. useFrame responds continuously to scene input. GSAP exposes an authored playhead. The spring responds to a changing destination. They are not three syntaxes for the same job.


Mixing systems: separate ownership before adding libraries

Production scenes often need more than one animation owner.

A hero can use GSAP for its entrance, useFrame for a continuously changing shader, and a spring for a hover state after the entrance completes.The problem begins when those systems write to the same property without a handoff.

Suppose GSAP controls:

JSX
group.current.rotation.y

while a frame callback also writes:

JSX
group.current.rotation.y = pointer.x * 0.3;

The last writer wins. More importantly, ownership is now difficult to reason about.

A useful scene hierarchy separates those transforms:

JSX
<group ref={introGroup}>  <group ref={interactionGroup}>    <ProductModel />  </group></group>

GSAP can own the parent group during the entrance. Pointer interaction can own the child group. Another valid boundary is lifecycle: GSAP owns the property until the intro finishes, then the interactive writer becomes active. The implementation can vary. The principle should not:

One animated property should have one clear owner at a time.


frameloop="demand" changes the integration contract

A continuously rendered scene is straightforward: scene values change and R3F keeps drawing frames. Demand rendering is different.

When a canvas uses:

JSX
<Canvas frameloop="demand">

R3F can stop rendering when nothing observable requires another frame. That is useful for scenes that spend meaningful time completely still.It also means an external animation library can mutate a Three.js property without R3F automatically knowing that another frame needs to be drawn.

frameloop demands change

R3F exposes invalidate() to request rendering in that situation. The current scheduler documentation keeps invalidation as part of the API.

A GSAP tween can therefore request a frame while it is active:

JSX
"use client"; import {  useLayoutEffect,  useRef,} from "react";import {  Canvas,  useThree,} from "@react-three/fiber";import { gsap } from "gsap";import type { Mesh } from "three"; function DemandTween() {  const mesh = useRef<Mesh>(null);  const invalidate = useThree(    (state) => state.invalidate  );   useLayoutEffect(() => {    if (!mesh.current) return;     const tween = gsap.to(      mesh.current.position,      {        x: 2,        duration: 1,        ease: "power2.out",        onUpdate: invalidate,      }    );     return () => {      tween.kill();    };  }, [invalidate]);   return (    <mesh ref={mesh}>      <boxGeometry />      <meshStandardMaterial />    </mesh>  );} export function DemandScene() {  return (    <Canvas frameloop="demand">      <ambientLight intensity={1.5} />      <DemandTween />    </Canvas>  );}

The contract is clear:

GSAP changes the property. invalidate() requests another R3F frame. R3F renders the result.

Do not add invalidation to every animation mechanically. A continuously rendered canvas already has frames arriving. This becomes relevant when the render strategy intentionally allows the scene to go idle.


Reduced motion: do not start the animation before you know the preference

A naive reduced-motion hook often begins with:

JSX
const [reduced, setReduced] = useState(false);

The media query is then read after mount.That creates an awkward edge case: an intro can begin at full motion before the user’s preference has been resolved.

Representing “not checked yet” explicitly avoids that assumption:

JSX
"use client"; import {  useEffect,  useState,} from "react"; export function usePrefersReducedMotion() {  const [reduced, setReduced] =    useState<boolean | null>(null);   useEffect(() => {    const query = window.matchMedia(      "(prefers-reduced-motion: reduce)"    );     const update = () => {      setReduced(query.matches);    };     update();     query.addEventListener("change", update);     return () => {      query.removeEventListener(        "change",        update      );    };  }, []);   return reduced;}

An animation can now wait until the preference is known:

JSX
function MotionAwareIntro() {  const reduced =    usePrefersReducedMotion();   if (reduced === null) {    return <StaticProduct />;  }   if (reduced) {    return <StaticProduct />;  }   return <AnimatedProductIntro />;}

The first render does not assume full motion. The reduced-motion version also does not need to be “the same camera flight, only faster.”

For a dramatic spatial move, the better alternative may be a static final composition or a short opacity change. For a decorative perpetual shader, it may be a still frame. For a hover effect, a smaller displacement may be enough.

Vault’s current dependency guidance makes the same broader recommendation: reduced motion can mean disabling movement, reducing distance, removing parallax or showing the resulting state immediately rather than blindly preserving the original effect.

The canvas is an interaction surface. It is not an accessibility exemption.

Keep essential copy and controls in semantic HTML where appropriate. Make hover-dependent interactions usable on touch. Preserve visible focus and keyboard operation for actual controls.


Choosing the correct owner does not make WebGL cheap. A transform tween and a fullscreen fragment shader are both “animation.” Their costs can be radically different.

Before shipping a motion-heavy R3F scene, test the production build and ask concrete questions:

  • Does the canvas continue rendering while nothing is visually changing?
  • What happens on a real mid-range phone rather than only a desktop development machine?
  • Does device pixel ratio materially increase GPU cost?
  • Are post-processing, particles, shader complexity, texture size or draw calls the actual bottleneck?
  • Are Three.js objects or temporary vectors being allocated unnecessarily inside the frame loop?
  • Are timelines, observers, listeners and other external resources cleaned up when components unmount?
  • Is heavy WebGL code imported only where it is needed?
  • Does the reduced-motion version remain coherent?
  • Does touch input have a deliberate behavior?
  • Do interactive DOM controls still work with keyboard and visible focus?

Vault’s live dependency documentation makes similar production recommendations for WebGL effects: keep browser-only work in client components, lazy-load heavy scenes where appropriate, run production builds, test mobile behavior and avoid running expensive canvas work unnecessarily.

Do not turn those questions into folklore such as “useFrame is faster” or “springs are more performant.” The renderer still pays for the scene you give it. Motion has to earn its place in the experience and in the frame budget.


Choose the owner, then tune the motion

Most React Three Fiber animation decisions can start with three questions.

  • Does the value need to be recalculated continuously from scene state? Start with useFrame.
  • Does the motion have authored beats, overlap and playhead logic? Start with GSAP.
  • Does application state define a destination that may change while the object is still moving? Start with @react-spring/three.
choose the owner

Then inspect the edges.

  • If two systems touch the same property, define an ownership boundary.
  • If the canvas renders on demand, make external mutation visible to R3F.
  • If the interaction has substantial spatial motion, design a reduced-motion alternative before the animation starts.
  • If the scene is expensive, profile its actual rendering workload instead of ranking animation libraries by intuition.

Declarative React does not mean declarative every frame. It means declaring the parts of the experience that deserve durable state, then letting the right machine handle the motion in between.

Explore WebGL & 3D Effects to preview patterns using effect-specific Three.js, React Three Fiber, GSAP and WebGL stacks

tags:React Three FiberGSAPuseFrame