Loaders

3D Dot Spinner Loader

A real lit 3D loader built with three.js: a chain of spheres, tapering from large to small, orbits continuously around a tilted ring.

Loading preview
Advertisement
Ad space reserved for a future placement

Source code

"use client";

import { useEffect, useRef } from "react";

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

interface DotSpinnerLoader3DProps {
  accentColor?: string;
  size?: DotSpinnerLoader3DSize;
  label?: string;
  className?: string;
}

const SIZE_PX: Record<DotSpinnerLoader3DSize, number> = {
  sm: 52,
  md: 72,
  lg: 96,
};

const SEGMENT_COUNT = 12;
const RADIUS = 0.85;

export function DotSpinnerLoader3D({
  accentColor = "#3f3cf2",
  size = "md",
  label = "Loading",
  className,
}: DotSpinnerLoader3DProps) {
  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(35, 1, 0.1, 10);
        camera.position.set(0, 0, 3.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 material = new THREE.MeshStandardMaterial({
          color: accentColor,
          metalness: 0.25,
          roughness: 0.4,
        });
        const geometry = new THREE.SphereGeometry(0.5, 48, 48);

        const group = new THREE.Group();
        group.rotation.x = 0.7;
        scene.add(group);

        const segments = Array.from({ length: SEGMENT_COUNT }, (_, i) => {
          const t = i / SEGMENT_COUNT;
          const mesh = new THREE.Mesh(geometry, material);
          mesh.scale.setScalar(0.24 - t * 0.16);
          group.add(mesh);
          return mesh;
        });

        const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        let angle = 0;

        const render = () => {
          angle += prefersReducedMotion ? 0.008 : 0.045;
          segments.forEach((mesh, i) => {
            const theta = angle + (i / SEGMENT_COUNT) * Math.PI * 2;
            mesh.position.set(Math.cos(theta) * RADIUS, Math.sin(theta) * RADIUS, 0);
          });
          renderer.render(scene, camera);
          frameId = requestAnimationFrame(render);
        };
        render();

        cleanup = () => {
          cancelAnimationFrame(frameId);
          geometry.dispose();
          material.dispose();
          renderer.dispose();
        };
      })
      .catch(() => {});

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

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

Usage

Import the component and use it like this.

import { DotSpinnerLoader3D } from "./DotSpinnerLoader3D";

export default function Example() {
  return <DotSpinnerLoader3D 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.