3D & Experimental
SphereWorld
Lit spheres circle one big sphere, cast shadows and show in each other's surface. Drag or use the arrow keys to walk around them. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/sphere-world.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type SphereWorldProps = {
/** Three six-digit hex colours. The spheres take turns. */
colors?: [string, string, string];
/** How many spheres, the big one included. 2 to 24. */
count?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Six-digit hex colour behind the spheres. */
background?: string;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const MAX_PIXELS = 1400000;
const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const fragment = (count: number) => `precision highp float;
uniform vec2 res;
uniform mat3 cam;
uniform vec3 back,glow;
uniform vec4 balls[${count}];
uniform vec3 hues[${count}];
const vec3 sun=vec3(.5,.7,.51);
bool first(vec3 o,vec3 d,out float t,out vec3 c,out vec3 h,out float r){
t=1e5;
for(int i=0;i<${count};i++){
vec3 q=o-balls[i].xyz;
float b=dot(q,d),e=b*b-dot(q,q)+balls[i].w*balls[i].w;
if(e<0.)continue;
float x=-b-sqrt(e);
if(x>.002&&x<t){t=x;c=balls[i].xyz;h=hues[i];r=balls[i].w;}
}
return t<1e4;
}
float lit(vec3 p){
float l=1.;
for(int i=0;i<${count};i++){
vec3 q=p-balls[i].xyz;
float b=dot(q,sun);
if(b<0.)l=min(l,smoothstep(balls[i].w*.86,balls[i].w*1.16,sqrt(max(dot(q,q)-b*b,0.))));
}
return l;
}
vec3 shade(vec3 p,vec3 n,vec3 h){
return h*(.1+.25*(n.y*.5+.5)+.95*max(dot(n,sun),0.)*lit(p));
}
vec3 room(vec3 d){
return mix(back,glow,pow(max(d.y*.5+.5,0.),3.)*.8)+smoothstep(.93,.97,dot(d,sun))*.7;
}
void main(){
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*min(res.x,res.y));
vec3 o=cam*vec3(0.,0.,7.),d=cam*normalize(vec3(uv,-2.7));
vec3 col=back+glow*.2*exp(-dot(uv,uv)*1.3);
float t,r;vec3 c,h;
if(first(o,d,t,c,h,r)){
vec3 p=o+d*t,n=(p-c)/r,s=reflect(d,n);
col=shade(p,n,h);
float f=pow(1.+dot(d,n),4.);
float t2,r2;vec3 c2,h2;
vec3 far=room(s);
if(first(p+n*.004,s,t2,c2,h2,r2)){vec3 q=p+n*.004+s*t2;far=shade(q,(q-c2)/r2,h2);}
col=mix(col,far,.1+.7*f);
col+=pow(max(dot(s,sun),0.),60.)*.8;
}
gl_FragColor=vec4(col,1.);
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext, text: string) {
const made = gl.createProgram();
for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, text]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* SphereWorld: lit spheres circle one big sphere and show in each other's surface. Drag or use the arrow keys to walk around them. Runs only while on screen. */
export function SphereWorld({
colors = ["#a78bfa", "#22d3ee", "#f472b6"],
count = 14,
speed = 1,
background = "#0b0a14",
label = "Spheres circling a large sphere",
className = "aspect-square w-80",
}: SphereWorldProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const hues = colors.join();
const total = Math.max(2, Math.min(24, Math.round(count)));
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl, fragment(total));
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
const tints = hues.split(",").map(rgb);
gl.uniform3fv(at("back"), rgb(background));
gl.uniform3fv(at("glow"), tints[0]);
gl.uniform3fv(at("hues"), Array.from({ length: total }, (_, i) => tints[i % 3]).flat());
const moons = Array.from({ length: total - 1 }, (_, i) => {
const ring = 1.55 + ((i * 0.37) % 1) * 1.1;
const seed = (i + 1) * 2.399;
return { ring, size: 0.13 + ((i * 0.61) % 1) * 0.24, lean: Math.sin(seed) * 0.42, phase: seed * 1.7, pace: (0.5 + ((i * 0.83) % 1) * 0.6) / Math.sqrt(ring) };
});
let raf = 0;
let last = 0;
let inView = false;
let time = reduce ? 3 : 0;
let yaw = 0.3;
let pitch = 0.32;
let drift = 0;
let dragging = false;
let lastX = 0;
let lastY = 0;
const draw = () => {
const c = Math.cos(yaw), s = Math.sin(yaw), ct = Math.cos(pitch), st = Math.sin(pitch);
gl.uniformMatrix3fv(at("cam"), false, [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c]);
const balls = [0, 0, 0, 1];
for (const m of moons) {
const a = m.phase + time * m.pace * speed;
balls.push(Math.cos(a) * m.ring, Math.sin(a) * m.lean * m.ring, Math.sin(a) * m.ring, m.size);
}
gl.uniform4fv(at("balls"), balls);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
if (!reduce) time += dt;
if (!dragging) {
yaw += drift;
drift *= 0.94;
}
draw();
raf = inView && !reduce ? requestAnimationFrame(frame) : 0;
};
const wake = () => {
if (inView && !raf) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(at("res"), canvas.width, canvas.height);
draw();
};
const down = (e: PointerEvent) => {
dragging = true;
drift = 0;
lastX = e.clientX;
lastY = e.clientY;
box.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent) => {
if (!dragging) return;
const step = (e.clientX - lastX) * 0.008;
yaw += step;
pitch = Math.max(-1.1, Math.min(1.1, pitch + (e.clientY - lastY) * 0.006));
drift = reduce ? 0 : step;
lastX = e.clientX;
lastY = e.clientY;
wake();
};
const up = () => {
dragging = false;
wake();
};
const key = (e: KeyboardEvent) => {
const turn = { ArrowLeft: [-0.2, 0], ArrowRight: [0.2, 0], ArrowUp: [0, -0.15], ArrowDown: [0, 0.15] }[e.key];
if (!turn) return;
e.preventDefault();
yaw += turn[0];
pitch = Math.max(-1.1, Math.min(1.1, pitch + turn[1]));
wake();
};
fit();
box.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
wake();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointerdown", down);
box.addEventListener("pointermove", move);
box.addEventListener("pointerup", up);
box.addEventListener("pointercancel", up);
box.addEventListener("keydown", key);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointerdown", down);
box.removeEventListener("pointermove", move);
box.removeEventListener("pointerup", up);
box.removeEventListener("pointercancel", up);
box.removeEventListener("keydown", key);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [hues, total, speed, background, reduce]);
return (
<div
ref={boxRef}
role="img"
aria-label={label}
tabIndex={0}
className={`relative cursor-grab touch-pan-y overflow-hidden active:cursor-grabbing ${className}`}
style={{ background }}
>
<div
ref={plainRef}
className="absolute left-1/2 top-1/2 aspect-square h-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: `radial-gradient(circle at 35% 30%, #fff8, ${colors[0]} 40%, ${background} 100%)` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/sphere-world.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/sphere-world-demo.tsx
import { SphereWorld } from "@/components/ui/sphere-world"; export default function SphereWorldExample() { return <SphereWorld className="size-full min-h-[260px]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| colors | [string, string, string] | ["#a78bfa", "#22d3ee", "#f472b6"] | Six-digit hex. The spheres take turns. |
| count | number | 14 | How many spheres, the big one included. 2 to 24. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| background | string | "#0b0a14" | Six-digit hex colour behind the spheres. |
| label | string | "Spheres circling a large sphere" | Name read out by screen readers. |
| className | string | "aspect-square w-80" | Set the size here. |