Workspace / component / shaders
Orbital Shader
paxfx add blocks/component-shaders-orbitallocal onlyTier: medium. Reduced motion: Orbits pause with trails settled.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";
export interface OrbitalShaderProps {
/** Number of orbiting points. Clamped to 6 to 80. */
count?: number;
/** Orbit speed multiplier. */
speed?: number;
/** Seed for orbit geometry; the same seed always builds the same system. */
seed?: number;
className?: string;
children?: ReactNode;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Rgb = readonly [number, number, number];
function readToken(el: HTMLElement, name: string, fallback: Rgb): Rgb {
const raw = getComputedStyle(el).getPropertyValue(name).trim();
if (raw.startsWith("#")) {
const hex = raw.length === 4 ? [...raw.slice(1)].map((c) => c + c).join("") : raw.slice(1, 7);
const n = Number.parseInt(hex, 16);
if (!Number.isNaN(n)) return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const match = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(raw);
if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
return fallback;
}
interface Body {
/** Semi-major axis as a fraction of the smaller container dimension. */
a: number;
/** Semi-minor to semi-major ratio. */
ratio: number;
/** Ellipse rotation, radians. */
tilt: number;
/** Starting angle, radians. */
phase: number;
/** Angular velocity, radians per second; inner orbits move faster. */
omega: number;
/** Point radius in CSS pixels. */
size: number;
/** True for paper-colored points, false for acid. */
pale: boolean;
}
/**
* Points orbit a shared center on tilted elliptical paths, Kepler style:
* inner orbits move faster. Trails decay through a translucent night fill
* each frame, so the system reads as a quiet long-exposure photograph.
* Geometry is stored in unit space and survives container resizes.
*/
export function OrbitalShader({
count = 26,
speed = 1,
seed = 9,
className,
children,
}: OrbitalShaderProps) {
const reduced = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const night = readToken(root, "--pax-night", [11, 13, 16]);
const acid = readToken(root, "--pax-acid", [200, 255, 0]);
const paper = readToken(root, "--pax-paper", [246, 242, 234]);
const bodyCount = Math.min(80, Math.max(6, Math.round(count)));
const rand = mulberry32(seed);
const bodies: Body[] = Array.from({ length: bodyCount }, () => {
const a = 0.16 + rand() * 0.3;
return {
a,
ratio: 0.5 + rand() * 0.38,
tilt: rand() * Math.PI,
phase: rand() * Math.PI * 2,
omega: (0.05 / Math.pow(a, 1.5)) * (0.8 + rand() * 0.4),
size: 1 + rand() * 1.6,
pale: rand() < 0.3,
};
});
let width = 0;
let height = 0;
let elapsed = 0;
let raf = 0;
let running = false;
let last = 0;
const position = (body: Body, angle: number): [number, number] => {
const scale = Math.min(width, height);
const major = body.a * scale;
const minor = major * body.ratio;
const ex = Math.cos(angle) * major;
const ey = Math.sin(angle) * minor;
const cos = Math.cos(body.tilt);
const sin = Math.sin(body.tilt);
return [width / 2 + ex * cos - ey * sin, height / 2 + ex * sin + ey * cos];
};
const drawCore = () => {
ctx.fillStyle = `rgba(${acid[0]},${acid[1]},${acid[2]},0.12)`;
ctx.beginPath();
ctx.arc(width / 2, height / 2, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(${acid[0]},${acid[1]},${acid[2]},0.9)`;
ctx.beginPath();
ctx.arc(width / 2, height / 2, 2.5, 0, Math.PI * 2);
ctx.fill();
};
const drawBodies = () => {
for (const body of bodies) {
const [x, y] = position(body, body.phase + elapsed * body.omega);
const [r, g, b] = body.pale ? paper : acid;
ctx.fillStyle = `rgba(${r},${g},${b},0.9)`;
ctx.beginPath();
ctx.arc(x, y, body.size, 0, Math.PI * 2);
ctx.fill();
}
};
const renderSettled = () => {
// Paused orbits with settled trails: faint full paths plus a decaying
// arc behind each point, drawn once with no loop.
ctx.fillStyle = `rgba(${night[0]},${night[1]},${night[2]},1)`;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = `rgba(${paper[0]},${paper[1]},${paper[2]},0.07)`;
ctx.lineWidth = 1;
for (const body of bodies) {
ctx.beginPath();
for (let s = 0; s <= 64; s += 1) {
const [x, y] = position(body, (s / 64) * Math.PI * 2);
if (s === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
const TRAIL_STEPS = 18;
for (const body of bodies) {
const [r, g, b] = body.pale ? paper : acid;
for (let s = TRAIL_STEPS; s >= 1; s -= 1) {
const angle = body.phase - (s / TRAIL_STEPS) * 0.9;
const [x, y] = position(body, angle);
ctx.fillStyle = `rgba(${r},${g},${b},${0.5 * (1 - s / TRAIL_STEPS)})`;
ctx.beginPath();
ctx.arc(x, y, body.size * (1 - (0.4 * s) / TRAIL_STEPS), 0, Math.PI * 2);
ctx.fill();
}
}
drawCore();
drawBodies();
};
const tick = (now: number) => {
if (!running) return;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += dt * speed;
ctx.fillStyle = `rgba(${night[0]},${night[1]},${night[2]},0.08)`;
ctx.fillRect(0, 0, width, height);
drawCore();
drawBodies();
raf = requestAnimationFrame(tick);
};
const start = () => {
if (running || reduced) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(raf);
};
const resize = () => {
const rect = root.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
width = rect.width;
height = rect.height;
if (reduced) {
renderSettled();
} else {
ctx.fillStyle = `rgba(${night[0]},${night[1]},${night[2]},1)`;
ctx.fillRect(0, 0, width, height);
}
};
const onVisibility = () => {
if (document.hidden) stop();
else start();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(root);
document.addEventListener("visibilitychange", onVisibility);
start();
return () => {
stop();
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [count, speed, seed, reduced]);
return (
<div
ref={rootRef}
className={cn(
"relative isolate min-h-[280px] overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
"bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
className,
)}
data-pax-asset="component-shaders-orbital"
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
<span className="sr-only">
A quiet solar system: points orbit a shared center leaving fading trails on a night surface.
</span>
<div className="relative z-[1]">{children}</div>
</div>
);
}