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.