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.