Loaders

Pendulum Loader 3D

A real lit 3D Newtons cradle (three.js): 3 pendulums driven by actual gravity, colliding and transferring momentum through the chain on contact.

Loading preview
Advertisement
Ad space reserved for a future placement

Source code

"use client";

import { useEffect, useRef } from "react";

type PendulumLoader3DSize = "sm" | "md" | "lg";

interface PendulumLoader3DProps {
  accentColor?: string;
  size?: PendulumLoader3DSize;
  label?: string;
  className?: string;
}

const SIZE_DIMENSIONS: Record<PendulumLoader3DSize, { width: number; height: number }> = {
  sm: { width: 80, height: 64 },
  md: { width: 112, height: 90 },
  lg: { width: 148, height: 118 },
};

// Newton's cradle: each pendulum obeys real gravity (angular acceleration = -(g / L) * sin(theta)),
// and adjacent bobs resolve real elastic collisions (equal mass -> full velocity swap) when they touch,
// so an impulse released on one ball actually propagates through the chain instead of being scripted.
const BOB_COUNT = 3;
const GRAVITY = 9.8;
const LENGTH = 1;
const PIVOT_Y = 0.55;
const BOB_RADIUS = 0.14;
const SPACING = BOB_RADIUS * 2;
const TIME_STEP = 1 / 60;
const COLLISION_PASSES = 4;

export function PendulumLoader3D({
  accentColor = "#3f3cf2",
  size = "md",
  label = "Loading",
  className,
}: PendulumLoader3DProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { width, height } = SIZE_DIMENSIONS[size];

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

    let frameId = 0;
    let cancelled = false;
    let cleanup = () => {};

    import("three")
      .then((THREE) => {
        if (cancelled) return;

        const renderer = new THREE.WebGLRenderer({
          canvas,
          alpha: true,
          antialias: true,
          powerPreference: "high-performance",
        });
        renderer.setPixelRatio(window.devicePixelRatio || 1);
        renderer.setSize(width, height, false);
        renderer.outputColorSpace = THREE.SRGBColorSpace;

        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 10);
        camera.position.set(0, 0.05, 4.2);
        scene.add(new THREE.AmbientLight(0xffffff, 0.5));
        const keyLight = new THREE.DirectionalLight(0xffffff, 1.3);
        keyLight.position.set(2, 2, 3);
        scene.add(keyLight);
        const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
        fillLight.position.set(-2, 1, 2);
        scene.add(fillLight);
        const rimLight = new THREE.DirectionalLight(0xffffff, 0.6);
        rimLight.position.set(0, 2, -3);
        scene.add(rimLight);

        const frameMaterial = new THREE.MeshStandardMaterial({ color: 0x475569, roughness: 0.5 });
        const pivotXs = Array.from({ length: BOB_COUNT }, (_, i) => (i - (BOB_COUNT - 1) / 2) * SPACING);

        const barGeometry = new THREE.BoxGeometry(pivotXs[pivotXs.length - 1] - pivotXs[0] + 0.3, 0.04, 0.04);
        const bar = new THREE.Mesh(barGeometry, frameMaterial);
        bar.position.set(0, PIVOT_Y + 0.05, 0);
        scene.add(bar);

        const rodGeometry = new THREE.CylinderGeometry(0.015, 0.015, LENGTH, 24);
        const bobGeometry = new THREE.SphereGeometry(BOB_RADIUS, 48, 48);
        const bobMaterial = new THREE.MeshStandardMaterial({
          color: accentColor,
          metalness: 0.4,
          roughness: 0.25,
        });

        const pivots = pivotXs.map((x) => new THREE.Vector3(x, PIVOT_Y, 0));
        const rods = pivotXs.map(() => {
          const rod = new THREE.Mesh(rodGeometry, frameMaterial);
          scene.add(rod);
          return rod;
        });
        const bobs = pivotXs.map(() => {
          const bob = new THREE.Mesh(bobGeometry, bobMaterial);
          scene.add(bob);
          return bob;
        });

        const up = new THREE.Vector3(0, 1, 0);
        const bobPos = pivotXs.map(() => new THREE.Vector3());

        const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        const angles = pivotXs.map((_, i) => (i === 0 ? 0.9 : 0));
        const velocities = pivotXs.map(() => 0);

        const step = () => {
          for (let i = 0; i < BOB_COUNT; i++) {
            velocities[i] += -(GRAVITY / LENGTH) * Math.sin(angles[i]) * TIME_STEP;
            angles[i] += velocities[i] * TIME_STEP;
          }

          for (let pass = 0; pass < COLLISION_PASSES; pass++) {
            for (let i = 0; i < BOB_COUNT - 1; i++) {
              const xi = pivotXs[i] + Math.sin(angles[i]) * LENGTH;
              const xi1 = pivotXs[i + 1] + Math.sin(angles[i + 1]) * LENGTH;
              const gap = xi1 - xi - BOB_RADIUS * 2;
              if (gap < 0 && velocities[i] > velocities[i + 1]) {
                const tmp = velocities[i];
                velocities[i] = velocities[i + 1];
                velocities[i + 1] = tmp;
                const correction = -gap / (2 * LENGTH);
                angles[i] -= correction;
                angles[i + 1] += correction;
              }
            }
          }
        };

        const render = () => {
          if (!prefersReducedMotion) {
            step();
          }

          for (let i = 0; i < BOB_COUNT; i++) {
            bobPos[i].set(pivotXs[i] + Math.sin(angles[i]) * LENGTH, PIVOT_Y - Math.cos(angles[i]) * LENGTH, 0);
            bobs[i].position.copy(bobPos[i]);
            rods[i].position.copy(pivots[i]).add(bobPos[i]).multiplyScalar(0.5);
            rods[i].quaternion.setFromUnitVectors(up, bobPos[i].clone().sub(pivots[i]).normalize());
          }

          renderer.render(scene, camera);
          frameId = requestAnimationFrame(render);
        };
        render();

        cleanup = () => {
          cancelAnimationFrame(frameId);
          barGeometry.dispose();
          frameMaterial.dispose();
          rodGeometry.dispose();
          bobGeometry.dispose();
          bobMaterial.dispose();
          renderer.dispose();
        };
      })
      .catch(() => {});

    return () => {
      cancelled = true;
      cleanup();
    };
  }, [accentColor, width, height]);

  return (
    <div
      role="status"
      aria-label={label}
      className={["pendulum-loader-3d", className].filter(Boolean).join(" ")}
      style={{ width, height }}
    >
      <canvas ref={canvasRef} className="pendulum-loader-3d-canvas" aria-hidden="true" />
      <style jsx>{`
        .pendulum-loader-3d {
          display: inline-block;
        }
        .pendulum-loader-3d-canvas {
          display: block;
          width: 100%;
          height: 100%;
        }
      `}</style>
    </div>
  );
}

Usage

Import the component and use it like this.

import { PendulumLoader3D } from "./PendulumLoader3D";

export default function Example() {
  return <PendulumLoader3D accentColor="#3f3cf2" size="md" label="Loading" />;
}

Dependencies

  • three
  • @types/three

Usage notes

Requires three.js: npm install three @types/three. Pass an accentColor hex, size (sm/md/lg), and an optional label.