Rive × Three.js

Two ways to combine them: Rive as a live texture, and Rive UI placed in 3D space.

What it is

Rive draws into a canvas, and Three.js can use any canvas as a texture: wrap it in a CanvasTexture, mark it for update each frame, and the animation plays on a 3D surface. For interactive Rive UI, a real DOM element is laid over the canvas and pinned to a point in the scene: each frame the point is projected to screen coordinates (vector.project(camera)) and the element moved there, so it follows the 3D object but keeps normal pointer events and accessibility.

When to use it

  • Texture: animated screens, signs and labels inside a 3D scene
  • Pinned DOM: interactive panels, tooltips and controls attached to 3D objects
  • Either: designers own the 2D motion, developers own the scene
Official docs ↗

1. Rive as a texture

Loading 3D scene…

The truck plays on the TV screen. The texture is only re-uploaded while the scene is animating.

2. Rive UI in 3D space

Loading 3D scene…

Orbit the camera: the rating card stays attached to the gem. Click a star.

Show code · src/app/rive-three/RiveTexture.tsx
"use client";

import "@/lib/rive-runtime";
import { OrbitControls, RoundedBox } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { Fit, Layout, Rive } from "@rive-app/react-canvas";
import { useEffect, useRef, useState } from "react";
import { type CanvasTexture, type Group, SRGBColorSpace } from "three";
import { useMotion } from "@/components/motion/MotionProvider";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";

const SIZE = 512;
const HEIGHT = Math.round(SIZE * (2.1 / 3.1)); // the screen's aspect ratio

// Rive draws into its own canvas; Three.js wraps that canvas in a
// CanvasTexture (declared in JSX, so react-three-fiber creates and disposes
// it) and re-uploads it each frame, so the animation plays on a 3D surface.
function Television({ canvas, animate }: { canvas: HTMLCanvasElement; animate: boolean }) {
  const tv = useRef<Group>(null);
  const texture = useRef<CanvasTexture>(null);

  useFrame(() => {
    if (!animate || !texture.current) return;
    texture.current.needsUpdate = true; // copy Rive's latest frame to the GPU
    if (tv.current) tv.current.rotation.y = Math.sin(performance.now() / 2500) * 0.5;
  });

  return (
    <group ref={tv} position={[0, 1.4, 0]}>
      <RoundedBox args={[3.4, 2.4, 0.25]} radius={0.08}>
        <meshStandardMaterial color="#26242f" roughness={0.6} />
      </RoundedBox>
      <mesh position={[0, 0, 0.13]}>
        <planeGeometry args={[3.1, 2.1]} />
        <meshBasicMaterial toneMapped={false}>
          {/* Rive draws sRGB colours. */}
          <canvasTexture ref={texture} attach="map" args={[canvas]} colorSpace={SRGBColorSpace} />
        </meshBasicMaterial>
      </mesh>
    </group>
  );
}

export function RiveTexture() {
  const { reduced } = useMotion();
  // Rive's player gets a canvas of its own, which Three.js reads as a texture.
  // Rive fits the artboard to the canvas's on-screen box and draws nothing if
  // that box is 0 × 0 (detached, or sized to zero), so the canvas is attached
  // to the page at its real size, just positioned off screen.
  const [canvas] = useState(() => {
    if (typeof document === "undefined") return null;
    const c = Object.assign(document.createElement("canvas"), { width: SIZE, height: HEIGHT });
    c.setAttribute("aria-hidden", "true");
    c.style.cssText = `position:fixed;top:0;left:-10000px;width:${SIZE}px;height:${HEIGHT}px;pointer-events:none`;
    return c;
  });
  const rive = useRef<Rive | null>(null);

  useEffect(() => {
    if (!canvas) return;
    document.body.append(canvas);
    const player = new Rive({ src: "/rive/truck.riv", canvas, stateMachines: "drive", autoplay: true, layout: new Layout({ fit: Fit.Cover }) });
    rive.current = player;
    return () => {
      player.cleanup();
      document.body.removeChild(canvas);
    };
  }, [canvas]);

  useEffect(() => {
    if (reduced) rive.current?.pause();
    else rive.current?.play();
  }, [reduced]);

  return (
    <ThreeCanvas camera={{ position: [0, 1.8, 6], fov: 40 }} label="A 3D television whose screen plays the Rive truck animation">
      {(animate) => (
        <>
          <color attach="background" args={["#14131a"]} />
          <hemisphereLight args={["#ffffff", "#3a3550", 1.2]} />
          <directionalLight position={[3, 5, 4]} intensity={1.5} />
          {canvas ? <Television canvas={canvas} animate={animate} /> : null}
          <mesh rotation-x={-Math.PI / 2}>
            <planeGeometry args={[20, 20]} />
            <meshStandardMaterial color="#2a2836" />
          </mesh>
          <OrbitControls target={[0, 1.4, 0]} minDistance={3.5} maxDistance={10} maxPolarAngle={Math.PI / 2.05} />
        </>
      )}
    </ThreeCanvas>
  );
}
Show code · src/app/rive-three/RiveInSpace.tsx
"use client";

import "@/lib/rive-runtime";
import { Float, OrbitControls } from "@react-three/drei";
import { Fit, Layout, useRive } from "@rive-app/react-canvas";
import { useCallback, useRef } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { Anchor, type ScreenPoint } from "@/components/three/Anchor";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";

// A normal React component, rendered over the canvas: it keeps real pointer
// events, accessibility and access to the page's context.
function RatingCard() {
  const { RiveComponent } = useRive(
    { src: "/rive/rating.riv", stateMachines: "State Machine 1", autoplay: true, layout: new Layout({ fit: Fit.Contain }) },
    { shouldUseIntersectionObserver: false },
  );
  return (
    <div className="w-56 rounded-xl border border-line bg-surface/95 p-3 text-fg shadow-lg">
      <p className="m-0 text-sm font-semibold">Rate this scene</p>
      <div className="h-24"><RiveComponent aria-label="Interactive star rating" role="img" /></div>
      <p className="m-0 text-xs text-muted">A DOM element pinned to a 3D point: hover and click work as usual.</p>
    </div>
  );
}

export function RiveInSpace() {
  const { reduced } = useMotion();
  const card = useRef<HTMLDivElement>(null);
  // Called every frame with the anchor's screen position.
  const moveCard = useCallback(({ x, y, visible }: ScreenPoint) => {
    const el = card.current;
    if (!el) return;
    el.style.transform = `translate(${x + 16}px, ${y}px) translate(0, -50%)`; // to the right of the point, centred on it
    el.style.visibility = visible ? "visible" : "hidden";
  }, []);
  return (
    <ThreeCanvas
      camera={{ position: [2.5, 2, 5], fov: 45 }}
      label="A floating 3D gem with a Rive star-rating card pinned above it"
      overlay={
        <div ref={card} className="absolute top-0 left-0 will-change-transform">
          <RatingCard />
        </div>
      }
    >
      {(animate) => (
        <>
          <color attach="background" args={["#14131a"]} />
          <hemisphereLight args={["#ffffff", "#3a3550", 1.2]} />
          <directionalLight position={[3, 5, 4]} intensity={1.5} />
          <Float speed={animate ? 2 : 0} rotationIntensity={reduced ? 0 : 0.6} floatIntensity={reduced ? 0 : 0.8}>
            <mesh position={[0, 1.2, 0]}>
              <octahedronGeometry args={[0.9]} />
              <meshStandardMaterial color="#7b6cf0" roughness={0.25} metalness={0.4} flatShading />
            </mesh>
          </Float>
          {/* The card's anchor: the gem's right-hand tip. */}
          <Anchor position={[0.9, 1.2, 0]} onMove={moveCard} />
          <mesh rotation-x={-Math.PI / 2}>
            <planeGeometry args={[20, 20]} />
            <meshStandardMaterial color="#2a2836" />
          </mesh>
          <OrbitControls target={[0, 1.5, 0]} minDistance={3} maxDistance={10} maxPolarAngle={Math.PI / 2.05} />
        </>
      )}
    </ThreeCanvas>
  );
}
Show code · src/components/three/Anchor.tsx
"use client";

import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import { Vector3 } from "three";

export type ScreenPoint = { x: number; y: number; visible: boolean };

// Tracks a point in the 3D scene on screen: every frame it projects the
// point to pixel coordinates within the canvas and reports them. The owner of
// a DOM overlay uses that to move it (through a ref: no React re-render).
export function Anchor({ position, onMove }: { position: [number, number, number]; onMove: (p: ScreenPoint) => void }) {
  const point = useRef(new Vector3());
  useFrame(({ camera, size }) => {
    const p = point.current.set(...position).project(camera); // -1…1 in both axes
    onMove({ x: ((p.x + 1) / 2) * size.width, y: ((1 - p.y) / 2) * size.height, visible: p.z <= 1 });
  });
  return null;
}