3D & Experimental
FloatingPlanet
A ringed planet with drifting cloud bands bobs in space and leans gently toward the mouse. The rings and the planet shadow each other. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/floating-planet.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type FloatingPlanetProps = {
/** Three six-digit hex colours for the cloud bands. */
colors?: [string, string, string];
/** Show the rings. */
rings?: boolean;
/** Show twinkling stars behind it. */
stars?: boolean;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** 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 = `precision highp float;
uniform vec2 res,drift;
uniform vec3 up,c1,c2,c3;
uniform float time,bob,rings,stars;
const vec3 sun=vec3(-.62,.42,.66);
float hash(vec3 p){return fract(sin(dot(p,vec3(12.9898,78.233,37.719)))*43758.5453);}
float noise(vec3 p){
vec3 i=floor(p),f=fract(p);
f=f*f*(3.-2.*f);
return mix(mix(mix(hash(i),hash(i+vec3(1.,0.,0.)),f.x),mix(hash(i+vec3(0.,1.,0.)),hash(i+vec3(1.,1.,0.)),f.x),f.y),
mix(mix(hash(i+vec3(0.,0.,1.)),hash(i+vec3(1.,0.,1.)),f.x),mix(hash(i+vec3(0.,1.,1.)),hash(i+vec3(1.,1.,1.)),f.x),f.y),f.z);
}
float fbm(vec3 p){
float v=0.,a=.5;
for(int i=0;i<4;i++){v+=a*noise(p);p=p*2.03+7.1;a*=.5;}
return v;
}
float band(float r){
float s=(r-1.38)/.82;
if(s<0.||s>1.)return 0.;
float a=.55+.28*sin(s*38.)+.17*sin(s*91.+1.);
a*=smoothstep(0.,.06,s)*(1.-smoothstep(.9,1.,s));
a*=smoothstep(.02,.05,abs(s-.62));
return clamp(a,0.,1.)*rings;
}
vec4 over(vec4 a,vec4 b){return a+b*(1.-a.a);}
void main(){
float side=min(res.x,res.y);
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side);
vec3 o=vec3(0.,0.,6.),d=normalize(vec3(uv,-2.6));
vec3 C=vec3(drift.x,bob+drift.y,0.);
vec3 e1=normalize(cross(up,vec3(0.,0.,1.))),e3=cross(e1,up);
vec4 col=vec4(0.);
if(stars>0.){
vec2 g=(uv-drift*.05)*22.,cell=floor(g);
float k=hash(vec3(cell,1.));
if(k>.86){
vec2 spot=fract(g)-.5-(vec2(hash(vec3(cell,2.)),hash(vec3(cell,3.)))-.5)*.6;
float s=(1.-smoothstep(0.,.07+k*.04,length(spot)))*(.55+.45*sin(time*(1.+k*3.)+k*40.));
col=vec4(vec3(s),s*.9);
}
}
vec3 q=o-C;
float b=dot(q,d),miss=sqrt(max(dot(q,q)-b*b,0.)),h=1.-miss*miss;
float tBall=h>0.?-b-sqrt(h):1e5;
float tRing=dot(C-o,up)/dot(d,up);
vec3 onRing=o+d*tRing-C;
float ring=tRing>0.?band(length(onRing)):0.;
float aura=exp(-max(miss-1.,0.)*9.)*.5;
col=over(vec4(c1*aura,aura*.9),col);
vec4 hoop=vec4(0.);
if(ring>0.){
float toSun=dot(onRing,sun);
float seen=toSun<0.?smoothstep(.96,1.06,sqrt(max(dot(onRing,onRing)-toSun*toSun,0.))):1.;
vec3 tone=mix(c2,c3,.5+.5*sin(length(onRing)*24.))*(.12+.95*seen);
hoop=vec4(tone*ring,ring);
}
if(tRing>tBall)col=over(hoop,col);
if(h>0.){
vec3 n=q+d*tBall;
float spin=time*.12;
vec3 l=vec3(dot(n,e1),dot(n,up),dot(n,e3));
vec3 s=vec3(l.x*cos(spin)-l.z*sin(spin),l.y,l.x*sin(spin)+l.z*cos(spin));
float w=fbm(s*vec3(3.,9.,3.));
vec3 body=mix(c1,c2,sin(l.y*11.+w*5.)*.5+.5);
body=mix(body,c3,smoothstep(.5,.72,fbm(s*vec3(2.,14.,2.)+3.)));
float cross_=-dot(n,up)/dot(sun,up);
float shade=cross_>0.?1.-.75*band(length(n+sun*cross_)):1.;
float lit=smoothstep(-.12,.95,dot(n,sun))*shade;
vec3 surface=body*(.05+1.15*lit)+c1*pow(1.+dot(d,n),3.)*.75*lit;
float cover=1.-smoothstep(1.-3.2/side,1.,miss);
col=over(vec4(surface*cover,cover),col);
}
if(tRing<=tBall)col=over(hoop,col);
gl_FragColor=col;
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* FloatingPlanet: a ringed planet with drifting cloud bands bobs in space and leans gently toward the mouse. Runs only while on screen. */
export function FloatingPlanet({
colors = ["#a78bfa", "#f472b6", "#fde68a"],
rings = true,
stars = true,
speed = 1,
label = "A ringed planet floating in space",
className = "aspect-square w-80",
}: FloatingPlanetProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const hues = colors.join();
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: true, antialias: false });
const made = gl && program(gl);
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 [one, two, three] = hues.split(",").map(rgb);
gl.uniform3fv(at("c1"), one);
gl.uniform3fv(at("c2"), two);
gl.uniform3fv(at("c3"), three);
gl.uniform1f(at("rings"), rings ? 1 : 0);
gl.uniform1f(at("stars"), stars ? 1 : 0);
let raf = 0;
let last = 0;
let inView = false;
let time = reduce ? 5 : 0;
let lookX = 0;
let lookY = 0;
let goalX = 0;
let goalY = 0;
const draw = () => {
const lean = -0.42 + lookX * 0.1;
const front = 0.34 + lookY * 0.12;
const size = Math.hypot(Math.sin(lean), Math.cos(lean), front);
gl.uniform3f(at("up"), Math.sin(lean) / size, Math.cos(lean) / size, front / size);
gl.uniform2f(at("drift"), lookX * 0.09, lookY * 0.09);
gl.uniform1f(at("bob"), reduce ? 0 : Math.sin(time * 0.8) * 0.07);
gl.uniform1f(at("time"), time % 1000);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt * speed;
lookX += (goalX - lookX) * 0.06;
lookY += (goalY - lookY) * 0.06;
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) 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 point = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const r = box.getBoundingClientRect();
goalX = ((e.clientX - r.left) / r.width - 0.5) * 2;
goalY = (0.5 - (e.clientY - r.top) / r.height) * 2;
};
const leave = () => {
goalX = 0;
goalY = 0;
};
fit();
box.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointermove", point);
box.addEventListener("pointerleave", leave);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointermove", point);
box.removeEventListener("pointerleave", leave);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [hues, rings, stars, speed, reduce]);
return (
<div ref={boxRef} role="img" aria-label={label} className={`relative overflow-hidden ${className}`}>
<div
ref={plainRef}
className="absolute left-1/2 top-1/2 aspect-square h-[38%] -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: `radial-gradient(circle at 30% 30%, ${colors[2]}, ${colors[1]} 45%, ${colors[0]} 100%)` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/floating-planet.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/floating-planet-demo.tsx
import { FloatingPlanet } from "@/components/ui/floating-planet"; export default function FloatingPlanetExample() { return ( <div className="h-full bg-[#0b0a14]"> <FloatingPlanet className="size-full min-h-[260px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| colors | [string, string, string] | ["#a78bfa", "#f472b6", "#fde68a"] | Six-digit hex colours for the cloud bands. |
| rings | boolean | true | Show the rings. |
| stars | boolean | true | Show twinkling stars behind it. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "A ringed planet floating in space" | Name read out by screen readers. |
| className | string | "aspect-square w-80" | Set the size here. |