Catalog npm
Back to packageRELEASE DETAILS

0.9.5

npm

3d-spinner

A lightweight, zero-dependency 3D spinner, loader, and progress indicator for JavaScript.

3d3d-spinnerloaderloadingprogressspinner
Published10 Jul 2026
Direct dependencies2
Transitive dependenciesNot calculated

README

Current package documentation

3d-spinner and beyond

tests npm bundle size license

Real 3D spinners, loaders, and progress indicators for the browser - in one line, configuration optional:

import { createSpinner } from "3d-spinner";
import { starSwarm } from "3d-spinner/prefabs";

createSpinner(document.getElementById("app"), starSwarm());
  • Zero configuration needed. Nine ready-made prefabs, each a complete spinner. Nothing to set up.
  • Fully configurable when you want it. Override any piece, compose your own from shapes, animations, motion paths, and materials, or drop in your own OBJ models.
  • Hardware 3D, automatically. By default it renders on the GPU through WebGPU or WebGL and falls back to a Canvas 2D software renderer that needs no GPU at all - picked for you, or pin one yourself. Same API either way.
  • Tiny download, fast start. Only the code you touch is loaded: a full prefab on WebGL is under 9 kB gzipped, renderer included, and the renderer itself is fetched on mount. Zero dependencies.
  • Works with your stack. ES modules, CommonJS, or a plain <script> tag; TypeScript types included. See Module formats.

Screenshots

starSwarm with custom HTMLpulsingStarfieldchargedOrb at 74%
starSwarm prefab with a custom HTML labelpulsingStarfield prefab with a Preparing launch labelchargedOrb progress prefab at 74 percent
ghostTrain progress prefabParticlesAnimation with glow texture
ghostTrain progress prefab on a tilted trackparticle loader with a Just a sec label

Install

npm install 3d-spinner

Quick start

A spinner is a renderer (the animation) plus a mode. The simplest case is an indeterminate spinner that runs until you stop it:

import { createSpinner } from "3d-spinner";
import { SpinAnimation } from "3d-spinner/animations/spin";

const spinner = createSpinner(document.getElementById("app"), {
  type: "indeterminate",
  animation: new SpinAnimation(),
});

// When the work is done:
spinner.stop();    // play the outro, then stop (leaves the element in place)
spinner.destroy(); // stop now and remove the element

// Or play the outro first, then remove the element:
spinner.stop();
await spinner.finished;
spinner.destroy();

Reporting progress

Drop the type (it defaults to "progress") and report progress yourself as work completes. Give SpinAnimation a progressAnimation to make it pop in, scale with progress, and show a label:

import { createSpinner } from "3d-spinner";
import { SpinAnimation } from "3d-spinner/animations/spin";

const spinner = createSpinner(document.getElementById("app"), {
  animation: new SpinAnimation({ progressAnimation: {} }),
});

spinner.setProgress(0.4); // smoothly advances toward 40%
spinner.setProgress(1);   // reaching 1 plays the outro

Choosing a shape

SpinAnimation spins a cube by default. Pass any built-in shape, or your own mesh:

import { SpinAnimation } from "3d-spinner/animations/spin";
import { tetrahedron } from "3d-spinner/engines/little-3d-engine";

new SpinAnimation({ shape: tetrahedron(), color: "#3b82f6" });

Shapes exported from 3d-spinner/engines/little-3d-engine include cube, tetrahedron, octahedron, pyramid, quad, and several spheres (uvSphere, icosphere, octaSphere, cubeSphere).

Mesh and particle colors are hex strings (#rgb or #rrggbb). Any other value makes the spinner's ready promise reject with a RangeError.

Surface materials

Faces are flat-shaded from their color by default. A Material adds ambient scaling, a specular highlight, and self-illumination on top, using the Wavefront MTL properties of the same names:

FieldMTLTypeDescription
ambientKa[r, g, b]Scales the scene ambient fill per channel, linear 0..1. Omit or [1,1,1] for the full ambient (the engine default).
specularKs[r, g, b]Highlight color and strength, linear 0..1. Omit for a matte surface.
shininessNsnumberHighlight tightness, 0..1000. Higher is smaller and glossier. Defaults to 32 when specular is set. Ignored without specular.
emissiveKe[r, g, b]Color added after shading, linear 0..1, so the face reads as self-lit.
opacityd (1 - Tr)numberFace dissolve, linear 0..1. 1 is opaque. Omit or 1 for an opaque face. Multiplies with instance transparency. Canvas 2D applies it; WebGL and WebGPU still use instance transparency only.

SpinAnimation takes a material that applies to every face, alongside color:

import { SpinAnimation } from "3d-spinner/animations/spin";

new SpinAnimation({
  color: "#3b82f6",
  material: { specular: [1, 1, 1], shininess: 64 },
});

Every shape builder also takes an optional trailing material, after its colors, for when you build the mesh yourself:

import { cube, icosphere } from "3d-spinner/engines/little-3d-engine";

cube(1, ["#3b82f6"], { specular: [1, 0.9, 0.6], shininess: 120 }); // (size, colors, material)
icosphere(1, 2, ["#111827"], { emissive: [0.1, 0.2, 0.3] });       // (size, detail, colors, material)

To apply one to a mesh from elsewhere, attachMaterial(mesh, material) sets it on every face in place.

Meshes loaded through the OBJ loader pick their materials up from the accompanying MTL file when useMtlColors is set - Kd becomes the face color, and Ka/Ks/Ns/Ke/d/Tr become the face material:

import { parseObj } from "3d-spinner/engines/little-3d-engine/loaders/obj";

const mesh = parseObj(objText, { mtl: mtlText, useMtlColors: true });

parseObj throws an error naming the line when the geometry is malformed: a vertex without three numeric coordinates, a face with fewer than three vertices, or a face index that points at a vertex not defined before it.

Materials work on all three backends. Canvas 2D computes the highlight once per face, so it lands flat, while WebGL and WebGPU compute it per pixel and produce a gradient across the face. Per-face opacity is applied on Canvas 2D; WebGL and WebGPU still use instance transparency only. The textured renderer variants do not apply materials.

How it fits together

A spinner is assembled from a few independent pieces, so you can swap one without touching the others:

  • createSpinner mounts the spinner into an element and runs a single animation loop.
  • Spinner type decides how progress is driven. progress is determinate - you report a value from 0 to 1 with setProgress. indeterminate is self-driving - it loops a synthetic progress on a timer until you stop(). Any animation works with either type.
  • Animation is the visual that plays (a spinning shape, a moving object). Each one lives at its own import path, so you load only the animation you use.
  • Intro and outro are the entrance and exit. The loop plays the intro once when the spinner starts and the outro when it finishes - progress reaching 1, or stop(). destroy() skips the outro and removes the element immediately.
  • Motion controller (for ObjectMotionAnimation) is a small, separate object that decides how a thing moves - a circle, square, figure-8, or wander. Swap the controller to change the path without changing the animation.
  • Transition (for ObjectMotionAnimation) is the intro/outro effect - by default the object flies in and out along the path; grow or shrink in place is available as an option.
  • The 3D engine is the renderer underneath: a small, dependency-free engine that draws shapes and meshes to a canvas, with swappable Canvas 2D, WebGL, and WebGPU backends.

Animations

Each animation is imported from its own subpath, so you only pull in the one you use.

ImportClassDescription
3d-spinner/animations/spinSpinAnimationA spinning 3D shape, a cube by default.
3d-spinner/animations/object-motionObjectMotionAnimationA mesh that follows a motion path, with an intro/outro you choose.
3d-spinner/animations/particlesParticlesAnimationA stream of camera-facing billboard particles: a burst, a fountain, snow, confetti.
3d-spinner/animations/charged-orbChargedOrbAnimationA progress story: a center orb pops out a ring of spark-trailing satellites as progress climbs.
3d-spinner/animations/ghost-trainGhostTrainAnimationA progress story: a translucent train gains a car per 2% of progress, then blasts off at 100%.
3d-spinner/animations/grid-assemblyGridAssemblyAnimationA progress story: 25 cubes circle the view edge and dock into a 5x5 grid as progress climbs.
3d-spinner/animations/rocket-launchRocketLaunchAnimationA progress story: a rocket lines up on the pad every 5% of progress; the row blasts off at 100%.
3d-spinner/composite-animationCompositeAnimationPlays several animations as layers of one spinner (how the prefabs combine effects).

Prefabs

Prefabs provide complete spinner options, including layered animation. They need no configuration and accept an optional override object.

PrefabModeDescription
crystalCometindeterminateA spinning crystal primitive with a luminous comet trail.
monochromeStreakindeterminateA fountain of black and white streaks that turn with their travel direction.
planeStarTrailindeterminateA small plane looping through a stream of colorful star particles.
pulsingStarfieldindeterminateHigh-shine particles drifting around a slowly pulsing HTML message.
starSwarmindeterminateBright star particles wandering around a centered loading message.
chargedOrbprogressA center orb pops out spark-trailing satellites as progress climbs.
ghostTrainprogressA translucent ice-cube train gains cars with progress and blasts off at 100%.
gridAssemblyprogress25 shapes fly in, circle the view edge, and dock into a 5x5 grid.
rocketLaunchprogressRockets line up on a launch pad and blast off in a loose stagger at 100%.
import { createSpinner } from "3d-spinner";
import { planeStarTrail } from "3d-spinner/prefabs";

const spinner = createSpinner(document.getElementById("app"), planeStarTrail());

Common overrides include backend, label, fadeLabel, and periodMs. Labels fade with the intro and outro by default; set fadeLabel: false to keep one fully visible. Motion prefabs also accept object and particles option objects, and the progress stories take their own layer overrides (orb, train, assembly). A label can be text or any HTMLElement.

const message = document.createElement("div");
message.innerHTML = "<strong>Preparing preview</strong>";

createSpinner(document.getElementById("app"), planeStarTrail({
  label: message,
  particles: { rate: 48 },
}));

ObjectMotionAnimation takes a motion controller from 3d-spinner/motion (circleMotion, squareMotion, figureEightMotion, wanderMotion) and optional entrance/exit transitions from 3d-spinner/motion/transitions (grow, shrink, enterFromObjectDirection, leaveInObjectDirection) - for example a figure-8 path with a fly-in and fly-out.

ParticlesAnimation emits fading billboard quads from the center. The emission options shape the effect: direction and spread aim it, gravity bends it, and rate, lifeMs, speed, size, spin, and colors style it. The stream is deterministic for a given seed. Emission starts on enter and stops on exit; the live particles fading out is the outro.

A texture option (an image URL or a drawable element such as a canvas) puts an image on every particle, tinted by the particle color, with the image's alpha shaping the particle. Textures render through a backend-specific textured renderer fetched on demand for Canvas 2D, WebGL, or WebGPU. Canvas 2D texture mapping is limited to planar four-vertex billboards.

import { createSpinner } from "3d-spinner";
import { ParticlesAnimation } from "3d-spinner/animations/particles";

const spinner = createSpinner(document.getElementById("app"), {
  type: "indeterminate",
  animation: new ParticlesAnimation({
    direction: { x: 0, y: 1, z: 0 },
    gravity: { x: 0, y: -1.6, z: 0 },
    speed: 1.5,
  }),
});

API

createSpinner(target, options)

Mounts a spinner inside target (an HTMLElement) and returns a Spinner. The options depend on the mode.

Progress (the default, type: "progress"):

OptionTypeDescription
animationSpinnerAnimationThe renderer to play. Required.
progressnumberInitial progress 0..1. A value above 0 plays the intro immediately.
timeoutMsnumberAuto-complete after this many milliseconds. NaN throws a RangeError. (timeout is the deprecated older name.)
untilDateAuto-complete at this time. If both are set, the earlier wins.
ariaLabelstringAccessible name of the hidden progress bar. Default "Loading".

Indeterminate (type: "indeterminate"):

OptionTypeDescription
animationSpinnerAnimationThe renderer to play. Required.
loop"bounce" | "restart""bounce" ramps 0 to 1 and back; "restart" repeats 0 to 1. Default "bounce".
periodMsnumberMilliseconds for one sweep. Must be finite and greater than zero. Default 2000.
ariaLabelstringAccessible name of the hidden progress bar. Default "Loading".

Spinner

MemberDescription
readyPromise that resolves once the animation can draw, and rejects with the error when it cannot (for example a pinned backend the browser lacks). On rejection the spinner stops animating and leaves the page as it is.
finishedPromise that resolves once the spinner has stopped animating: its outro finished (after stop(), progress reaching 1, timeoutMs, or until), it was stopped before its intro, it was destroyed, or its setup failed. Never rejects.
setProgress(target)Advance progress toward target (0..1). No-op for an indeterminate spinner.
stop()Play the outro, then stop animating. Keeps the injected element. Await finished to know when the outro is done.
destroy()Stop immediately and remove the injected element. Safe to call more than once.

The spinner renders straight into target, so its size and placement come from your own CSS. It adds a canvas and a label and never replaces what is already inside. A static target becomes position: relative so the overlays have a positioning context; any other position is kept.

An animation instance drives one spinner: passing the same instance to a second createSpinner throws. Prefab functions return a fresh animation on every call.

const spinner = createSpinner(target, gridAssembly({ backend: "webgpu" }));
spinner.ready.catch((error) => showFallback(error.message));

Accessibility

Every spinner adds one visually hidden ARIA progressbar to its target. A progress spinner keeps aria-valuenow at the rounded percentage (written only when it changes); an indeterminate one has no value, which is how ARIA marks indeterminate progress. Its name comes from the ariaLabel option, default "Loading", also accepted by every prefab:

createSpinner(target, gridAssembly({ ariaLabel: "Uploading photos" }));

The overlay text drawn over the animation is hidden from assistive technology, so progress is announced once even when layers stack labels. A custom HTMLElement label keeps its own semantics.

The spinner does not change its motion on its own when the system asks for reduced motion. prefersReducedMotion() reads that setting, for picking a calmer visual or none:

import { createSpinner, prefersReducedMotion } from "3d-spinner";
import { SpinAnimation } from "3d-spinner/animations/spin";

createSpinner(target, {
  type: "indeterminate",
  animation: prefersReducedMotion()
    ? new SpinAnimation({ spinX: 0.0002, spinY: 0.0003 })
    : new SpinAnimation(),
});

Rendering backend

By default the backend is "auto": the spinner picks the best renderer the browser supports - WebGPU, then WebGL, then the Canvas 2D software renderer, which needs no GPU and runs anywhere a canvas does. Nothing to configure, and no capability checks to write.

createSpinner(el, starSwarm());               // auto: WebGPU -> WebGL -> Canvas 2D

Name a backend to pin it:

new SpinAnimation({ backend: "webgl" }); // "auto" (default), "canvas2d", "webgl", or "webgpu"

Every prefab, animation, and the engine itself takes the same backend option:

starSwarm({ backend: "webgpu" });             // a prefab
new ParticlesAnimation({ backend: "webgl" }); // an animation
new Little3dEngine({ backend: "canvas2d" });  // the engine directly

Before 0.9.9 the default was "canvas2d"; pass backend: "canvas2d" to keep that behavior.

Backends are loaded on demand, and "auto" decides before it imports anything: it probes for a WebGPU adapter and a WebGL2 context directly, so the code for a backend it rejects is never fetched. If the chosen backend still fails to start, "auto" moves on to the next one. Pinning a backend the browser cannot run rejects spinner.ready rather than falling back - "auto" is the resilient choice. To decide yourself, detectBackendSupport() and chooseBackend() are exported from the engine.

The same fallback covers a renderer that stops working after it started: if the GPU device or the WebGL context is lost (a driver reset, or the browser reclaiming contexts), "auto" switches to the next backend and keeps animating. A pinned backend has nothing to switch to, so its canvas is removed and a console warning says why.

Renderer-specific features can look different between Canvas 2D, WebGL, and WebGPU. In particular, transparent shapes are an approximate visual effect rather than a pixel-identical cross-renderer result.

Use transparency.mode to choose visible-front-face transparency or a two-pass transparent-solid effect. Opacity defaults to 0.35 for one-sided rendering. Two-sided rendering defaults to front 0.56 and back 0.84.

new SpinAnimation({
  backend: "webgl",
  transparency: { mode: "two-sided", opacity: 0.6 }, // front 0.6, back 0.4
});

For two-sided rendering, explicit frontOpacity and backOpacity values override the shorthand for their respective sides.

The engine

The renderer is a small, self-contained 3D engine, exported on its own in case you want it directly:

  • 3d-spinner/engines/little-3d-engine - the engine (Little3dEngine), shapes, and textures.
  • 3d-spinner/engines/little-3d-engine/loaders/obj - a minimal OBJ loader (parseObj).
  • 3d-spinner/engines/little-tween-engine - a standalone tween and easing engine (LittleTweenEngine).

Each has no dependencies of its own.

Module formats

Every example above is ES modules - the primary, tree-shakeable format. Use a bundler or native <script type="module">; the engine draws to a canvas, so there is no server-side rendering. ES modules do not load over file://, so serve the page over HTTP rather than opening the file directly.

Two other formats are published for cases where ESM isn't an option:

CommonJS (require), for older Node tooling:

const { createSpinner } = require("3d-spinner");
const { SpinAnimation } = require("3d-spinner/animations/spin");

Browser global (IIFE), for a plain <script> tag with no bundler or module loader. This build bundles the whole public API onto one window.Spinner3D object:

<script src="https://unpkg.com/3d-spinner"></script>
<script>
  const spinner = Spinner3D.createSpinner(document.getElementById("app"), {
    type: "indeterminate",
    animation: new Spinner3D.SpinAnimation(),
  });
</script>

Development

npm install
npm run build   # compile src/ to dist/ (ESM + type declarations, CJS, and a browser-global build)
npm test        # build, then run the unit tests and the consumer type check
npm run test:browser  # real rendering in headless Chromium (npx playwright install chromium first)
npm run lint    # type-aware ESLint over src/
npm run format  # Prettier (format:check is what CI runs)
npm run dev     # serve this folder; open /examples/ or /examples/prefabs-progress.html

License

MIT (c) RuneL

Downloads & popularity

Awaiting observations

No metric observations yet

Download counts and popularity will appear here as the collector receives them.

Declared dependencies 2

Native requirements
2 direct targetsNot calculated transitive count
Package / targetRequirementRelationship
esbuild
Environment
{
  "local_name": "esbuild",
  "alias": false,
  "peer_metadata": null
}
^0.28.1development
typescript
Environment
{
  "local_name": "typescript",
  "alias": false,
  "peer_metadata": null
}
^5.7.0development

Distribution files

1 collected
Distribution 1

Source distribution

Source file
Checksums from source
{
  "shasum": "73abc255bdb3dad120fe3e7b83370fb44655179d",
  "tarball": "https://registry.npmjs.org/3d-spinner/-/3d-spinner-0.9.5.tgz",
  "fileCount": 145,
  "integrity": "sha512-a8yaVMM2bILRzxOxY2tlda8RsP5cL7umfNQ8w23rP6R33tUQ+jfe9oKs18c2IS2RXcHp1xvbLbASBrUpMemVWg==",
  "signatures": [
    {
      "sig": "MEUCIFK4daXFCYWUMnlN/J32S2b+oRp3wD75d2+xyOrLBRymAiEA/qO2D6v58WXKHerfoL9UnYmgJVawgeb0NmVwJ1eq5T8=",
      "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"
    }
  ],
  "unpackedSize": 1148243
}

Repository

PENDING
runelaang/3d-spinner

Shared repository data · GitHub

License detected in repository
Not collected

Metadata history

Meaningful changes
Current collected state Current

Observed since 20 Oct, 19:37 UTC

No superseded metadata states collected. Unchanged observations do not create new revisions.

Record identity

package_version:c86f2cf2697a1dd8a771b3198dfede33fd7c5ab4304d8b95172031fd2e277fb3

Other collected versions

1.1.030 Sept1.0.029 Sept0.9.3125 Sept0.9.3025 Sept0.9.2925 Sept0.9.1625 SeptView all collected versions →