Workspace / component / shaders

Orbital Shader

Points orbit a shared center on elliptical paths with trails that decay in canvas alpha. Reads as a quiet solar system for hero corners.

Workspacecomponentshaders

paxfx add blocks/component-shaders-orbitallocal only

Tier: 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>
  );
}