AI & ML

Manim-Web: 3Blue1Brown Mathematical Animations in React

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Build Mathematical Animations in React with Manim-Web

  1. Scaffold a Next.js project with TypeScript and install the Manim-Web port, KaTeX, and Three.js as dependencies.
  2. Disable server-side rendering for any component that touches the WebGL canvas using dynamic imports or the "use client" directive.
  3. Define scene construct functions that create mobjects (polygons, squares, Tex labels) and sequence animations with async/await.
  4. Wrap the scene in a React component, initializing the WebGL renderer in useEffect and disposing of GPU resources on unmount.
  5. Bind React state (sliders, step buttons) to scene parameters, rebuilding mobjects when values change and guarding controls during playback.
  6. Optimize by memoizing scene construction, handling WebGL context loss, and respecting prefers-reduced-motion for accessibility.
  7. Deploy as a static Next.js export to Vercel or Netlify, ensuring KaTeX fonts and CSS are bundled with long-TTL cache headers.

If you've ever watched a 3Blue1Brown video and thought "I want that on my website," you're not alone. Grant Sanderson's Manim engine transformed how people visualize mathematics, powering those signature blue-and-brown animations that make linear algebra and calculus click. Now, a growing ecosystem of JavaScript ports makes it possible to build a manim react tutorial project that produces mathematical animations web experiences directly in the browser.

Table of Contents

In this tutorial, you'll build a fully interactive Pythagorean theorem proof scene with React state controls. By the end, you'll have a working geometry visualization with sliders that dynamically resize the triangle and step-through buttons that walk users through the proof, all rendered in real-time via WebGL.

Prerequisites: Intermediate React knowledge, basic coordinate geometry, and Node.js 18+ installed.

From Python to JS: How Manim-Web Works Under the Hood

To understand what a browser-based Manim port actually does, you need to understand what the original Manim does and why porting it is hard.

Manim's Original Architecture

Both major Python Manim variants (Grant Sanderson's 3b1b/manim and the community-maintained ManimCommunity/manim) follow the same core pattern. You define a Scene class with a construct() method. Inside that method, you create mathematical objects ("mobjects") like Circle, Line, or MathTex, then call self.play() to run animations on them. The renderer (Cairo for 2D in ManimCommunity, OpenGL for GPU-accelerated work in 3b1b/manim) captures each frame and writes it to disk. FFmpeg stitches those frames into a video file.

This is a batch process, through and through. No interactivity. No DOM. No event loop responding to user input. The output is a .mp4 file, not a living interface.

The JavaScript Translation Layer

Several community projects have tackled porting Manim's concepts to the web. The approach that works best for React integration involves reimplementing core mobject types (geometric primitives, text objects, coordinate planes) in JavaScript, replacing Cairo/OpenGL rendering with a WebGL backend (often built on Three.js or a custom renderer), mapping the scene graph to a structure React can manage, and swapping the frame-by-frame file-write loop for requestAnimationFrame-driven real-time rendering.

The result: a library where you declare mobjects in JSX, sequence animations with an imperative API, and mount the whole thing inside a React component tree.

A note on terminology: Throughout this article, I use "Manim-Web" as a general term for this architectural pattern, not the name of a single canonical library. Several implementations exist at varying maturity levels, including community experiments on GitHub. The code examples below present a clean, idiomatic API that synthesizes patterns from these projects. If you're evaluating a specific library, check its README for supported features and API surface before committing. You may need to adapt import paths and API details to match whichever implementation you choose.

What's Supported (and What's Not Yet)

Current JavaScript Manim ports generally cover 2D geometry primitives (circles, polygons, lines, arcs), LaTeX rendering via KaTeX or MathJax integration, basic camera movements and scene transitions, and standard animations like FadeIn, Transform, and ReplacementTransform.

The gaps are real, though. Advanced 3D scenes, shader-based effects (like those in recent 3b1b videos), and some niche mobject types are still missing. Community roadmaps typically track these in GitHub issues.

Here's how the same animation looks in Python versus JSX:

# Python Manim (ManimCommunity)
from manim import *

class CircleToSquare(Scene):
    def construct(self):
        circle = Circle(color=BLUE, fill_opacity=0.5)
        square = Square(color=RED, fill_opacity=0.5)
        self.play(FadeIn(circle))
        self.play(Transform(circle, square))
        self.wait(1)
// JSX Manim-Web equivalent
// NOTE: "manim-web" is a placeholder package name representing the general
// pattern. Adapt imports to whichever JS Manim port you choose.
import { ManimScene, Circle, Square, FadeIn, Transform } from 'manim-web';

function CircleToSquare() {
  return (
    <ManimScene
      construct={(scene) => {
        const circle = new Circle({ color: '#58C4DD', fillOpacity: 0.5 });
        const square = new Square({ color: '#FC6255', fillOpacity: 0.5 });
        scene.play(new FadeIn(circle));
        scene.play(new Transform(circle, square));
        scene.wait(1);
      }}
    />
  );
}

The mental model is nearly identical. The construct callback receives a scene handle, and you sequence animations imperatively. The rendering, though, happens live in a <canvas> element instead of writing to disk.

Setting Up Your Project with Next.js

Scaffolding the Next.js App

Start by creating a new Next.js project with TypeScript:

npx create-next-app@latest manim-proof-demo --typescript --app --tailwind
cd manim-proof-demo

# Install the Manim-Web library and peer dependencies
# NOTE: "manim-web" is a placeholder name. Replace with your chosen
# JS Manim port (e.g., a community GitHub package).
npm install manim-web katex three @types/three

# If using MathJax instead of KaTeX:
# npm install mathjax-full

Because Manim-Web relies on WebGL and the window object, you need to prevent server-side rendering for any component that touches the canvas. In your next.config.ts (Next.js 15+ uses TypeScript config by default; use next.config.js for older versions):

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['manim-web'],
  webpack: (config, { isServer }) => {
    if (isServer) {
      // Ensure canvas-related packages aren't bundled server-side
      config.resolve.fallback = {
        ...config.resolve.fallback,
        canvas: false,
        fs: false,
      };
    }
    return config;
  },
};

module.exports = nextConfig;

Any component that renders a Manim scene must be a client component. Either add "use client" at the top of the file, or use Next.js dynamic imports with SSR disabled:

import dynamic from 'next/dynamic';

const PythagoreanProof = dynamic(() => import('./PythagoreanProof'), {
  ssr: false,
  loading: () => <div className="w-full h-96 bg-gray-900 animate-pulse" />,
});

Project Structure Conventions

I've found that separating scene logic from React UI wrappers keeps things manageable as you add more visualizations. Here's the layout I recommend:

src/
  scenes/           # Pure scene definitions (construct functions)
    pythagorean.ts
    circleToSquare.ts
  components/       # React wrapper components with UI controls
    PythagoreanProof.tsx
    SceneViewer.tsx
  lib/
    manim/          # Shared helpers, custom mobjects, theme config
      colors.ts
      layout.ts

Scene files export construct functions. Components import those functions and pass them into <ManimScene>. This separation makes scenes testable in isolation and reusable across different UI contexts.

Your First Manim-Web Scene in React

Rendering a Basic Scene Component

Let's build the simplest possible scene: a circle that fades in, then transforms into a square. This verifies your setup works end-to-end.

// src/components/HelloManimWeb.tsx
"use client";

import React, { useRef, useEffect } from 'react';
// NOTE: Adapt these imports to your chosen JS Manim port's actual API.
import {
  Scene,
  Circle,
  Square,
  FadeIn,
  Transform,
  WebGLRenderer,
} from 'manim-web';

export default function HelloManimWeb() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sceneRef = useRef<Scene | null>(null);
  const mountedRef = useRef(true);

  useEffect(() => {
    mountedRef.current = true;

    if (!canvasRef.current) return;

    const renderer = new WebGLRenderer({
      canvas: canvasRef.current,
      antialias: true,
      alpha: true,
    });

    const scene = new Scene(renderer);
    sceneRef.current = scene;

    async function construct() {
      const circle = new Circle({
        radius: 1.5,
        color: '#58C4DD',
        fillOpacity: 0.5,
      });
      const square = new Square({
        sideLength: 2.5,
        color: '#FC6255',
        fillOpacity: 0.5,
      });

      scene.add(circle);

      await scene.play(new FadeIn(circle, { duration: 1 }));
      if (!mountedRef.current) return;

      await scene.play(new Transform(circle, square, { duration: 1.5 }));
      if (!mountedRef.current) return;

      await scene.wait(1);
    }

    construct();

    return () => {
      mountedRef.current = false;
      scene.dispose();
      renderer.dispose();
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      width={800}
      height={450}
      className="w-full max-w-3xl rounded-lg bg-gray-900"
    />
  );
}

Understanding the Animation Lifecycle

The construct function is async because each scene.play() call returns a Promise that resolves when the animation completes. This mirrors Python Manim's blocking self.play() behavior but uses JavaScript's native async/await instead of a custom coroutine scheduler.

The critical piece is the cleanup function in useEffect. WebGL contexts are a limited resource; browsers typically allow around 8 to 16 active contexts before silently dropping older ones. The dispose() calls release GPU memory and kill the animation frame loop.

// Lifecycle control pattern with imperative handle
const sceneRef = useRef<Scene | null>(null);
const mountedRef = useRef(true);

useEffect(() => {
  mountedRef.current = true;
  // ... scene setup ...

  return () => {
    mountedRef.current = false;
    if (sceneRef.current) {
      sceneRef.current.pause();   // Stop RAF loop
      sceneRef.current.dispose(); // Release WebGL resources
      sceneRef.current = null;
    }
  };
}, []);

// Expose play/pause/seek to parent components
const handlePause = () => sceneRef.current?.pause();
const handleResume = () => sceneRef.current?.resume();
const handleSeek = (t: number) => sceneRef.current?.seekTo(t);

This breaks when the component unmounts during an active animation await chain. Guard each await call with a boolean ref (mountedRef.current) or wire up an AbortController.

Building a Geometry Proof Scene: The Viral Asset

This is the centerpiece. We'll build a complete Pythagorean theorem visualization that constructs a right triangle, draws squares on each side, and animates the area decomposition to prove a² + b² = c².

Designing the Proof

The visual narrative follows five steps:

  1. Draw the right triangle with labeled sides a, b, c
  2. Construct squares on each side, color-coded
  3. Display the area labels (a², b², c²)
  4. Animate the decomposition showing the two smaller squares' areas filling the larger square
  5. Reveal the equation a² + b² = c²

I structure these as an array of scene actions so we can later bind step navigation to React state.

Constructing Mobjects in JSX

Here's the full scene definition for the proof geometry:

// src/scenes/pythagorean.ts
// NOTE: Adapt these imports to your chosen JS Manim port's actual API.
import {
  Scene,
  Polygon,
  Square as MSquare,
  Line,
  Tex,
  Group,
  RIGHT,
  UP,
  LEFT,
  DOWN,
  ORIGIN,
} from 'manim-web';

export interface PythagoreanConfig {
  a: number;  // short side
  b: number;  // long side
}

export function createPythagoreanMobjects(config: PythagoreanConfig) {
  const { a, b } = config;
  const c = Math.sqrt(a * a + b * b);

  // Right triangle vertices
  const A = [0, 0, 0] as const;       // right angle
  const B = [a, 0, 0] as const;       // along x-axis
  const C = [0, b, 0] as const;       // along y-axis

  const triangle = new Polygon({
    vertices: [A, B, C],
    color: '#FFFFFF',
    strokeWidth: 2,
    fillOpacity: 0.1,
  });

  // Square on side a (along x-axis, below the triangle)
  const squareA = new MSquare({
    sideLength: a,
    color: '#58C4DD',   // blue
    fillOpacity: 0.4,
    strokeWidth: 2,
  }).moveTo([a / 2, -a / 2, 0]);

  // Square on side b (along y-axis, to the left of the triangle)
  const squareB = new MSquare({
    sideLength: b,
    color: '#83C167',   // green
    fillOpacity: 0.4,
    strokeWidth: 2,
  }).moveTo([-b / 2, b / 2, 0]);

  // Square on hypotenuse c
  // Position the center of the hypotenuse-side square correctly.
  // The hypotenuse midpoint is at (a/2, b/2). The square extends
  // outward from the triangle; its exact placement depends on your
  // library's rotation/anchor API. Adjust as needed.
  const hypMidX = a / 2;
  const hypMidY = b / 2;
  const angle = Math.atan2(b, a);
  // Offset the square center outward from the hypotenuse
  const offsetX = hypMidX + (c / 2) * Math.sin(angle);
  const offsetY = hypMidY - (c / 2) * (-Math.cos(angle));

  const squareC = new MSquare({
    sideLength: c,
    color: '#FC6255',   // red
    fillOpacity: 0.3,
    strokeWidth: 2,
  }).rotateTo(Math.atan2(b, a))
    .moveTo([offsetX, offsetY, 0]);

  // LaTeX labels
  const labelA = new Tex({ text: 'a', color: '#58C4DD' })
    .nextTo(squareA, DOWN, { buffer: 0.3 });
  const labelB = new Tex({ text: 'b', color: '#83C167' })
    .nextTo(squareB, LEFT, { buffer: 0.3 });
  const labelC = new Tex({ text: 'c', color: '#FC6255' })
    .nextTo(squareC, UP, { buffer: 0.3 });

  const areaLabelA = new Tex({ text: 'a^2', color: '#58C4DD', fontSize: 1.2 })
    .moveTo(squareA.getCenter());
  const areaLabelB = new Tex({ text: 'b^2', color: '#83C167', fontSize: 1.2 })
    .moveTo(squareB.getCenter());
  const areaLabelC = new Tex({ text: 'c^2', color: '#FC6255', fontSize: 1.2 })
    .moveTo(squareC.getCenter());

  const equation = new Tex({
    text: 'a^2 + b^2 = c^2',
    color: '#FFFF00',
    fontSize: 1.5,
  }).moveTo([0, -2.5, 0]);

  return {
    triangle, squareA, squareB, squareC,
    labelA, labelB, labelC,
    areaLabelA, areaLabelB, areaLabelC,
    equation,
  };
}

Sequencing Animations

The construct function orchestrates the five proof steps:

// src/scenes/pythagorean.ts (continued)
import {
  FadeIn,
  GrowFromCenter,
  Write,
  Transform,
  AnimationGroup,
  Indicate,
  FadeOut,
} from 'manim-web';

export type StepFunction = (scene: Scene) => Promise<void>;

export function buildProofSteps(config: PythagoreanConfig): StepFunction[] {
  const m = createPythagoreanMobjects(config);

  return [
    // Step 1: Draw the triangle
    async (scene) => {
      scene.add(m.triangle);
      await scene.play(new FadeIn(m.triangle, { duration: 1 }));
      await scene.play(new AnimationGroup([
        new FadeIn(m.labelA, { duration: 0.5 }),
        new FadeIn(m.labelB, { duration: 0.5 }),
        new FadeIn(m.labelC, { duration: 0.5 }),
      ]));
    },

    // Step 2: Grow squares on each side
    async (scene) => {
      scene.add(m.squareA);
      await scene.play(new GrowFromCenter(m.squareA, { duration: 0.8 }));
      scene.add(m.squareB);
      await scene.play(new GrowFromCenter(m.squareB, { duration: 0.8 }));
      scene.add(m.squareC);
      await scene.play(new GrowFromCenter(m.squareC, { duration: 0.8 }));
    },

    // Step 3: Show area labels
    async (scene) => {
      scene.add(m.areaLabelA);
      scene.add(m.areaLabelB);
      scene.add(m.areaLabelC);
      await scene.play(new AnimationGroup([
        new Write(m.areaLabelA, { duration: 0.6 }),
        new Write(m.areaLabelB, { duration: 0.6 }),
        new Write(m.areaLabelC, { duration: 0.6 }),
      ]));
    },

    // Step 4: Highlight the relationship
    async (scene) => {
      await scene.play(new Indicate(m.squareA, { color: '#FFFF00' }));
      await scene.play(new Indicate(m.squareB, { color: '#FFFF00' }));
      await scene.play(new Indicate(m.squareC, { color: '#FFFF00' }));
    },

    // Step 5: Reveal the equation
    async (scene) => {
      scene.add(m.equation);
      await scene.play(new Write(m.equation, { duration: 1.5 }));
      await scene.wait(0.5);
    },
  ];
}

Adding LaTeX Labels and Annotations

The Tex mobject renders LaTeX strings through a KaTeX bridge. If you installed KaTeX as a peer dependency, configure it in your library setup:

import { configureTex } from 'manim-web';
// Ensure KaTeX CSS is loaded (e.g., in your layout or global CSS):
// import 'katex/dist/katex.min.css';

configureTex({
  engine: 'katex',        // or 'mathjax'
  macros: {
    '\\vec': '\\mathbf',  // custom macros
  },
});

// Then use Tex normally
const label = new Tex({
  text: '\\sqrt{a^2 + b^2} = c',
  color: '#FFFFFF',
  fontSize: 1.0,
});
label.nextTo(triangle, RIGHT, { buffer: 0.5 });

KaTeX is significantly faster than MathJax for initial render. Simple expressions come back in well under 50ms, while MathJax's first render drags noticeably due to font loading and layout computation. Stick with KaTeX unless you need MathJax's broader LaTeX command coverage. MathJax supports a much larger subset of LaTeX, including packages like amsmath and physics more comprehensively.

Making It Interactive: React State Meets Manim Animations

This is where the 3blue1brown javascript approach breaks away from the Python original. Python Manim generates a fixed video. A React integration lets users manipulate the visualization in real time.

Python Manim generates a fixed video. A React integration lets users manipulate the visualization in real time.

Binding React State to Scene Parameters

Here's the complete interactive component with sliders controlling triangle dimensions:

// src/components/PythagoreanProof.tsx
"use client";

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Scene, WebGLRenderer } from 'manim-web';
import {
  buildProofSteps,
  PythagoreanConfig,
  StepFunction,
} from '../scenes/pythagorean';

const TOTAL_STEPS = 5;

const STEP_LABELS = [
  'Draw triangle',
  'Construct squares',
  'Show areas',
  'Highlight relationship',
  'Reveal equation',
];

export default function PythagoreanProof() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sceneRef = useRef<Scene | null>(null);
  const rendererRef = useRef<WebGLRenderer | null>(null);
  const stepsRef = useRef<StepFunction[]>([]);

  const [sideA, setSideA] = useState(3);
  const [sideB, setSideB] = useState(4);
  const [currentStep, setCurrentStep] = useState(-1);
  const [isPlaying, setIsPlaying] = useState(false);

  const hypotenuse = Math.sqrt(sideA ** 2 + sideB ** 2);

  // Initialize renderer and scene once
  useEffect(() => {
    if (!canvasRef.current) return;

    const renderer = new WebGLRenderer({
      canvas: canvasRef.current,
      antialias: true,
    });
    rendererRef.current = renderer;

    const scene = new Scene(renderer);
    sceneRef.current = scene;

    return () => {
      scene.dispose();
      renderer.dispose();
      sceneRef.current = null;
      rendererRef.current = null;
    };
  }, []);

  // Rebuild mobjects when dimensions change
  useEffect(() => {
    const scene = sceneRef.current;
    if (!scene) return;

    scene.clear();
    setCurrentStep(-1);

    const config: PythagoreanConfig = { a: sideA, b: sideB };
    stepsRef.current = buildProofSteps(config);
  }, [sideA, sideB]);

  const playStep = useCallback(async (stepIndex: number) => {
    const scene = sceneRef.current;
    const steps = stepsRef.current;
    if (!scene || stepIndex < 0 || stepIndex >= steps.length) return;

    setIsPlaying(true);
    try {
      await steps[stepIndex](scene);
      setCurrentStep(stepIndex);
    } finally {
      setIsPlaying(false);
    }
  }, []);

  const handleNext = useCallback(() => {
    if (isPlaying) return;
    const nextStep = currentStep + 1;
    if (nextStep < TOTAL_STEPS) {
      playStep(nextStep);
    }
  }, [currentStep, isPlaying, playStep]);

  const handlePrevious = useCallback(() => {
    if (isPlaying) return;
    // Reset and replay up to previous step
    const prevStep = currentStep - 1;
    if (prevStep < 0 || !sceneRef.current) return;

    const scene = sceneRef.current;
    scene.clear();

    // Rebuild steps since scene was cleared
    const config: PythagoreanConfig = { a: sideA, b: sideB };
    stepsRef.current = buildProofSteps(config);

    setCurrentStep(-1);
    setIsPlaying(true);

    (async () => {
      try {
        for (let i = 0; i <= prevStep; i++) {
          await stepsRef.current[i](scene);
          setCurrentStep(i);
        }
      } finally {
        setIsPlaying(false);
      }
    })();
  }, [currentStep, isPlaying, sideA, sideB]);

  return (
    <div className="flex flex-col items-center gap-6 p-6 bg-gray-950 rounded-xl">
      <canvas
        ref={canvasRef}
        width={800}
        height={500}
        className="w-full max-w-3xl rounded-lg"
        role="img"
        aria-label={`Pythagorean theorem proof visualization. Triangle with sides ${sideA}, ${sideB}, and hypotenuse ${hypotenuse.toFixed(2)}. Currently on step: ${currentStep >= 0 ? STEP_LABELS[currentStep] : 'not started'}.`}
      />

      {/* Dimension sliders */}
      <div className="flex gap-8">
        <label className="flex flex-col items-center text-blue-400">
          Side a: {sideA}
          <input
            type="range"
            min={1}
            max={6}
            step={0.5}
            value={sideA}
            onChange={(e) => setSideA(parseFloat(e.target.value))}
            className="w-40"
            disabled={isPlaying}
          />
        </label>
        <label className="flex flex-col items-center text-green-400">
          Side b: {sideB}
          <input
            type="range"
            min={1}
            max={6}
            step={0.5}
            value={sideB}
            onChange={(e) => setSideB(parseFloat(e.target.value))}
            className="w-40"
            disabled={isPlaying}
          />
        </label>
        <span className="flex flex-col items-center text-red-400">
          Hypotenuse c: {hypotenuse.toFixed(2)}
        </span>
      </div>

      {/* Step navigation */}
      <div className="flex gap-4 items-center">
        <button
          onClick={handlePrevious}
          disabled={currentStep <= 0 || isPlaying}
          className="px-4 py-2 bg-gray-700 rounded disabled:opacity-30"
        >
Previous
        </button>
        <span className="text-gray-400 min-w-48 text-center">
          {currentStep >= 0
            ? `Step ${currentStep + 1}/${TOTAL_STEPS}: ${STEP_LABELS[currentStep]}`
            : 'Press Next to begin'}
        </span>
        <button
          onClick={handleNext}
          disabled={currentStep >= TOTAL_STEPS - 1 || isPlaying}
          className="px-4 py-2 bg-blue-600 rounded disabled:opacity-30"
        >
          Next        </button>
      </div>
    </div>
  );
}

Adding Step Navigation Controls

The navigation pattern above bakes in a deliberate design choice: stepping backward replays all steps from scratch rather than trying to "undo" individual animations. Simpler. More reliable.

When I built an interactive calculus visualization for an edtech visualization react prototype, I initially tried to reverse each animation individually. Disaster. Transform animations aren't perfectly reversible when intermediate state is lost. Replaying from scratch added about 200ms of latency for a five-step scene. Users didn't notice.

The sliders are disabled during animation playback (isPlaying state) to prevent dimension changes mid-animation. Skip this guard and the scene enters an inconsistent state where mobjects reference stale positions. Don't skip this guard.

Skip this guard and the scene enters an inconsistent state where mobjects reference stale positions. Don't skip this guard.

Performance Optimization and Production Concerns

WebGL Performance Tips

Three patterns keep frame rates smooth:

Memoize scene construction. The buildProofSteps function creates new mobject instances every time dimensions change. Wrap expensive geometry calculations in useMemo:

import { useMemo } from 'react';

const steps = useMemo(
  () => buildProofSteps({ a: sideA, b: sideB }),
  [sideA, sideB]
);

Handle WebGL context loss. Browsers can evict WebGL contexts under memory pressure. Add a recovery handler:

useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;

  const handleLost = (e: WebGLContextEvent) => {
    e.preventDefault();
    console.warn('WebGL context lost, will restore on recovery');
  };
  const handleRestored = () => {
    // Re-initialize renderer and scene
    console.info('WebGL context restored');
  };

  canvas.addEventListener('webglcontextlost', handleLost);
  canvas.addEventListener('webglcontextrestored', handleRestored);

  return () => {
    canvas.removeEventListener('webglcontextlost', handleLost);
    canvas.removeEventListener('webglcontextrestored', handleRestored);
  };
}, []);

Avoid re-creating mobjects on every render. Use React.memo on the canvas wrapper component and keep mobject references stable via refs rather than state.

Accessibility and Fallbacks

Canvas content is opaque to screen readers. The aria-label on our canvas element provides a text description that updates with each step, but for production edtech visualization react applications, you should also add a parallel DOM description that expands on each step's mathematical content, respect the prefers-reduced-motion media query by auto-pausing animations, and provide a <noscript> fallback with a static image of the completed proof.

// Check for reduced motion preference (client-side only)
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

useEffect(() => {
  const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
  setPrefersReducedMotion(mq.matches);

  const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
  mq.addEventListener('change', handler);
  return () => mq.removeEventListener('change', handler);
}, []);

// In your construct function:
const duration = prefersReducedMotion ? 0 : 1;
await scene.play(new FadeIn(triangle, { duration }));

Setting duration to 0 makes objects appear instantly, preserving the educational content while respecting user preferences.

Setting duration to 0 makes objects appear instantly, preserving the educational content while respecting user preferences.

Deployment and Next Steps

Deploying on Vercel or Netlify

A Next.js app with WebGL content deploys to Vercel without drama since all rendering happens client-side. Two things to watch: make sure KaTeX CSS and font files land in your bundle (add import 'katex/dist/katex.min.css' in your layout or component), and set appropriate Cache-Control headers for font assets. KaTeX fonts are static and cache-safe with long TTLs.

Note that next export was removed in Next.js 13.3+ in favor of output: 'export' in next.config.js. If you're using the App Router (as scaffolded above), set output: 'export' in your config and run next build to produce a static export. Verify that your dynamic imports with { ssr: false } work correctly in the exported output. Test locally with npx serve out before deploying.

Where to Go from Here

The ecosystem for browser-based mathematical animations web content is growing up fast. Combine Manim-Web scenes with MDX for interactive math articles where prose and visualization live side by side. Build custom mobjects for your domain: chemistry orbital diagrams, physics force vectors, statistical distributions. Check out projects like MathBox and JSXGraph for complementary approaches to mathematical visualization. The 3D capabilities of these JavaScript ports will keep expanding as WebGPU support spreads across browsers.

The source code for the complete Pythagorean proof component is available as a self-contained file. Drop it into any Next.js project with the dependencies installed, and you'll have a working interactive proof in under five minutes. From there, the same patterns extend to any mathematical concept you want to teach: swap the mobjects, rewrite the steps, and you have a new visualization that would have required a full Manim render pipeline and video hosting just a few years ago.