Torus
import { useEffect, useRef } from 'react';
const css = `
.loader-torus { display: block; width: 120px; height: 120px; color: light-dark(#373a40, #f1f2f3); }
`;
const MAJOR = 30; // points round the ring
const MINOR = 10; // points round the tube
const R = 1; // ring radius
const r = 0.42; // tube radius
// The torus as points: angle u round the ring, v round the tube.
const POINTS = Array.from({ length: MAJOR * MINOR }, (_, i) => {
const u = ((i % MAJOR) / MAJOR) * Math.PI * 2;
const v = (Math.floor(i / MAJOR) / MINOR) * Math.PI * 2;
return [(R + r * Math.cos(v)) * Math.cos(u), (R + r * Math.cos(v)) * Math.sin(u), r * Math.sin(v)];
});
export function TorusLoader() {
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const el = canvas.current!;
const ctx = el.getContext('2d')!;
const size = el.clientWidth;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = size * dpr;
el.height = size * dpr;
ctx.scale(dpr, dpr);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
const draw = (now: number) => {
const t = still ? 1.2 : now / 1000;
const spin = t * 0.7; // round its own axis: the points flow round the ring
const ax = 1.05 + Math.sin(t * 0.6) * 0.25; // tilted so the hole stays in view, rocking
const ay = Math.sin(t * 0.4) * 0.5; // and swaying side to side
const cs = Math.cos(spin);
const ss = Math.sin(spin);
const cx = Math.cos(ax);
const sx = Math.sin(ax);
const cy = Math.cos(ay);
const sy = Math.sin(ay);
// Canvas can't read CSS variables: the ink is the element's own colour (per frame, for theme switches).
ctx.fillStyle = getComputedStyle(el).color;
ctx.clearRect(0, 0, size, size);
const projected = POINTS.map(([x0, y0, z]) => {
const x = x0 * cs - y0 * ss; // spin about the torus axis (z)
const y = x0 * ss + y0 * cs;
const x1 = x * cy + z * sy; // sway about y
const z1 = -x * sy + z * cy;
const y2 = y * cx - z1 * sx; // tilt about x
const z2 = y * sx + z1 * cx;
const k = 1 / (3.2 - z2); // perspective
return { x: size / 2 + x1 * k * size * 0.8, y: size / 2 + y2 * k * size * 0.8, z: z2 };
});
// Far points first, so near ones paint over them.
projected.sort((a, b) => a.z - b.z);
for (const p of projected) {
const depth = (p.z + R + r) / (2 * (R + r)); // 0 far … 1 near
ctx.globalAlpha = 0.12 + depth * 0.88;
ctx.beginPath();
ctx.arc(p.x, p.y, 0.6 + depth * 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (!still) raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<canvas ref={canvas} className="loader-torus" role="status" aria-label="Loading" />
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderTorus(el) on the mounted element instead.
<canvas class="loader-torus" role="status" aria-label="Loading"></canvas>.loader-torus { display: block; width: 120px; height: 120px; color: light-dark(#373a40, #f1f2f3); }function initLoaderTorus(root) {
var ctx = root.getContext && root.getContext('2d');
if (!ctx) return;
var MAJOR = 30; // points round the ring
var MINOR = 10; // points round the tube
var R = 1; // ring radius
var r = 0.42; // tube radius
var POINTS = [];
for (var i = 0; i < MAJOR * MINOR; i++) {
var u = ((i % MAJOR) / MAJOR) * Math.PI * 2;
var v = (Math.floor(i / MAJOR) / MINOR) * Math.PI * 2;
POINTS.push([(R + r * Math.cos(v)) * Math.cos(u), (R + r * Math.cos(v)) * Math.sin(u), r * Math.sin(v)]);
}
var size = root.clientWidth;
var dpr = Math.min(2, devicePixelRatio || 1);
root.width = size * dpr;
root.height = size * dpr;
ctx.scale(dpr, dpr);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0;
function draw(now) {
var t = still ? 1.2 : now / 1000;
var cs = Math.cos(t * 0.7); // spin round its own axis: the points flow round the ring
var ss = Math.sin(t * 0.7);
var ax = 1.05 + Math.sin(t * 0.6) * 0.25; // tilted so the hole stays in view, rocking
var ay = Math.sin(t * 0.4) * 0.5; // and swaying side to side
var cx = Math.cos(ax);
var sx = Math.sin(ax);
var cy = Math.cos(ay);
var sy = Math.sin(ay);
// A canvas can't read CSS colours: the ink is the element's own colour, every frame.
ctx.fillStyle = getComputedStyle(root).color;
ctx.clearRect(0, 0, size, size);
var projected = POINTS.map(function (p) {
var x = p[0] * cs - p[1] * ss;
var y = p[0] * ss + p[1] * cs;
var x1 = x * cy + p[2] * sy;
var z1 = -x * sy + p[2] * cy;
var y2 = y * cx - z1 * sx;
var z2 = y * sx + z1 * cx;
var k = 1 / (3.2 - z2); // perspective
return { x: size / 2 + x1 * k * size * 0.8, y: size / 2 + y2 * k * size * 0.8, z: z2 };
});
// Far points first, so near ones paint over them.
projected.sort(function (a, b) { return a.z - b.z; });
projected.forEach(function (p) {
var depth = (p.z + R + r) / (2 * (R + r)); // 0 far, 1 near
ctx.globalAlpha = 0.12 + depth * 0.88;
ctx.beginPath();
ctx.arc(p.x, p.y, 0.6 + depth * 1.3, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
if (!still) raf = requestAnimationFrame(draw);
}
raf = requestAnimationFrame(draw);
return function () { cancelAnimationFrame(raf); };
}
document.querySelectorAll('.loader-torus').forEach(initLoaderTorus);Goal: Create a "Torus" loader.
Visual behavior: A torus made of 300 points tumbling in 3D: it spins round its own axis so the points flow round the ring, rocks and sways, and the near side glows larger and brighter while the far side falls away.
Technique:
- Points sit on a torus: angle u round the ring (30 steps) and v round the tube (10 steps), ring radius 1 and tube radius 0.42.
- Each frame every point spins about the torus axis, sways about Y and tilts about X (so the hole stays in view), then takes a perspective divide.
- Points are sorted far to near and drawn on a canvas with size and alpha rising with depth.
- The fill colour is the canvas element's computed CSS colour, read every frame so a theme change applies.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion one still frame is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Torus" loader.
Visual behavior: A torus made of 300 points tumbling in 3D: it spins round its own axis so the points flow round the ring, rocks and sways, and the near side glows larger and brighter while the far side falls away.
Technique:
- Points sit on a torus: angle u round the ring (30 steps) and v round the tube (10 steps), ring radius 1 and tube radius 0.42.
- Each frame every point spins about the torus axis, sways about Y and tilts about X (so the hole stays in view), then takes a perspective divide.
- Points are sorted far to near and drawn on a canvas with size and alpha rising with depth.
- The fill colour is the canvas element's computed CSS colour, read every frame so a theme change applies.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion one still frame is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A torus made of 300 points tumbling in 3D: it spins round its own axis so the points flow round the ring, rocks and sways, and the near side glows larger and brighter while the far side falls away.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- continuous, loop, 3d
- Techniques
- raf, 3d transform, perspective
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion one still frame is drawn.