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.