Loaders
Stairs Ball Loader 3D
A real lit 3D loading spinner (three.js). A ball hops up a receding staircase on real parabolic projectile arcs, then leaps back down to the first step to loop seamlessly, with real rolling rotation.
Loading preview
Advertisement
Ad space reserved for a future placement
Source code
"use client";
import { useEffect, useRef } from "react";
type StairsBallLoader3DSize = "sm" | "md" | "lg";
interface StairsBallLoader3DProps {
accentColor?: string;
size?: StairsBallLoader3DSize;
label?: string;
className?: string;
}
const SIZE_PX: Record<StairsBallLoader3DSize, number> = {
sm: 64,
md: 88,
lg: 116,
};
// 4 steps up (real parabolic hops, y = lerp + arc * 4t(1-t), the exact closed-form
// path of a constant-gravity projectile) then one big hop back down to the first
// step, so the loop never needs to snap/teleport the ball.
const STEP_COUNT = 5;
const STEP_RISE = 0.28;
const STEP_DEPTH = 0.42;
const STEP_WIDTH = 0.56;
const BALL_RADIUS = 0.13;
const CLIMB_HOP_TIME = 0.55;
const RETURN_HOP_TIME = 0.9;
export function StairsBallLoader3D({
accentColor = "#3f3cf2",
size = "md",
label = "Loading",
className,
}: StairsBallLoader3DProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const px = SIZE_PX[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(px, px, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 10);
const totalHeight = STEP_COUNT * STEP_RISE;
const totalDepth = STEP_COUNT * STEP_DEPTH;
camera.position.set(1.3, totalHeight * 0.75, 1.9);
camera.lookAt(0, totalHeight * 0.45, -totalDepth * 0.45);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.3);
keyLight.position.set(2, 3, 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.5);
rimLight.position.set(0, 2, -3);
scene.add(rimLight);
const stepMaterial = new THREE.MeshStandardMaterial({ color: 0x94a3b8, roughness: 0.55 });
// Each step is solid from the ground up to its own tread height, one depth
// slot further back than the last, so consecutive steps share a face and
// the staircase reads as one connected solid instead of floating slabs.
const stepGeometries: InstanceType<typeof THREE.BoxGeometry>[] = [];
const stepPositions = Array.from({ length: STEP_COUNT }, (_, i) => {
const treadY = (i + 1) * STEP_RISE;
const treadZ = -(i * STEP_DEPTH + STEP_DEPTH / 2);
const geometry = new THREE.BoxGeometry(STEP_WIDTH, treadY, STEP_DEPTH);
stepGeometries.push(geometry);
const step = new THREE.Mesh(geometry, stepMaterial);
step.position.set(0, treadY / 2, treadZ);
scene.add(step);
return new THREE.Vector3(0, treadY, treadZ);
});
const ballMaterial = new THREE.MeshStandardMaterial({
color: accentColor,
metalness: 0.35,
roughness: 0.3,
});
const ballGeometry = new THREE.SphereGeometry(BALL_RADIUS, 48, 48);
const ball = new THREE.Mesh(ballGeometry, ballMaterial);
scene.add(ball);
const hopFrom = [0, 1, 2, 3, STEP_COUNT - 1];
const hopTo = [1, 2, 3, STEP_COUNT - 1, 0];
const hopDuration = [
CLIMB_HOP_TIME,
CLIMB_HOP_TIME,
CLIMB_HOP_TIME,
CLIMB_HOP_TIME,
RETURN_HOP_TIME,
];
const cycleTime = hopDuration.reduce((sum, d) => sum + d, 0);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
let previousTime = performance.now();
let traveled = 0;
let lastX = stepPositions[0].x;
let lastZ = stepPositions[0].z;
const render = (now: number) => {
const dt = Math.min((now - previousTime) / 1000, 0.05);
previousTime = now;
elapsed = (elapsed + dt * (prefersReducedMotion ? 0.15 : 1)) % cycleTime;
let hopIndex = 0;
let cursor = elapsed;
while (hopIndex < hopDuration.length - 1 && cursor >= hopDuration[hopIndex]) {
cursor -= hopDuration[hopIndex];
hopIndex++;
}
const hopT = cursor / hopDuration[hopIndex];
const from = stepPositions[hopFrom[hopIndex]];
const to = stepPositions[hopTo[hopIndex]];
const arcHeight = hopIndex === hopFrom.length - 1 ? 0.44 : 0.26;
const x = from.x + (to.x - from.x) * hopT;
const z = from.z + (to.z - from.z) * hopT;
const y = from.y + (to.y - from.y) * hopT + arcHeight * 4 * hopT * (1 - hopT);
traveled += Math.hypot(x - lastX, z - lastZ);
lastX = x;
lastZ = z;
ball.rotation.x = -traveled / BALL_RADIUS;
ball.position.set(x, y + BALL_RADIUS, z);
renderer.render(scene, camera);
frameId = requestAnimationFrame(render);
};
frameId = requestAnimationFrame(render);
cleanup = () => {
cancelAnimationFrame(frameId);
stepGeometries.forEach((geometry) => geometry.dispose());
stepMaterial.dispose();
ballGeometry.dispose();
ballMaterial.dispose();
renderer.dispose();
};
})
.catch(() => {});
return () => {
cancelled = true;
cleanup();
};
}, [accentColor, px]);
return (
<div
role="status"
aria-label={label}
className={["stairs-ball-loader-3d", className].filter(Boolean).join(" ")}
style={{ width: px, height: px }}
>
<canvas ref={canvasRef} className="stairs-ball-loader-3d-canvas" aria-hidden="true" />
<style jsx>{`
.stairs-ball-loader-3d {
display: inline-block;
}
.stairs-ball-loader-3d-canvas {
display: block;
width: 100%;
height: 100%;
}
`}</style>
</div>
);
}
Usage
Import the component and use it like this.
import { StairsBallLoader3D } from "./StairsBallLoader3D";
export default function Example() {
return <StairsBallLoader3D 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.