Workspace / component / shaders
Plasma Shader
paxfx add blocks/component-shaders-plasmalocal onlyTier: medium. Reduced motion: Plasma freezes on a composed frame.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";
export interface PlasmaShaderProps {
/** Playback speed multiplier. */
speed?: number;
/** Spatial frequency of the interference pattern. */
scale?: number;
className?: string;
children?: ReactNode;
}
/** Starting internal downscale; the governor raises it if frames run long. */
const BASE_DOWNSCALE = 4;
const MAX_DOWNSCALE = 10;
/** Frame budget for the per-pixel pass, in milliseconds. */
const FRAME_BUDGET_MS = 8;
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;
}
/**
* Classic sine-interference plasma: four sin terms over x, y, and time,
* summed and remapped to the night-to-acid brand palette. Rendered per pixel
* on Canvas 2D at reduced internal resolution, with a governor that shrinks
* the internal buffer further whenever a frame exceeds its budget.
*/
export function PlasmaShader({ speed = 1, scale = 1, className, children }: PlasmaShaderProps) {
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", { alpha: false });
if (!ctx) return;
const night = readToken(root, "--pax-night", [11, 13, 16]);
const acid = readToken(root, "--pax-acid", [200, 255, 0]);
const low = document.createElement("canvas");
const lowCtx = low.getContext("2d");
if (!lowCtx) return;
let width = 0;
let height = 0;
let lowW = 0;
let lowH = 0;
let downscale = BASE_DOWNSCALE;
let image: ImageData | null = null;
let elapsed = 0;
let raf = 0;
let running = false;
let last = 0;
let calmFrames = 0;
const sizeLow = () => {
lowW = Math.max(1, Math.round(width / downscale));
lowH = Math.max(1, Math.round(height / downscale));
low.width = lowW;
low.height = lowH;
image = lowCtx.createImageData(lowW, lowH);
};
const render = () => {
if (!image || lowW === 0 || lowH === 0) return;
const started = performance.now();
const data = image.data;
const t = elapsed;
const freq = (6.2 * scale) / Math.max(lowW, lowH);
const cx = lowW * freq * (0.5 + Math.sin(t * 0.21) * 0.18);
const cy = lowH * freq * (0.5 + Math.cos(t * 0.17) * 0.18);
let i = 0;
for (let y = 0; y < lowH; y += 1) {
const py = y * freq;
const rowA = Math.sin(py * 2.1 - t * 0.62);
for (let x = 0; x < lowW; x += 1) {
const px = x * freq;
const v =
Math.sin(px * 1.7 + t) +
rowA +
Math.sin((px + py) * 1.3 + t * 0.45) +
Math.sin(Math.hypot(px - cx, py - cy) * 2.9 - t * 0.8);
// v is in [-4, 4]; shape it toward the dark end so acid reads as highlight.
const v01 = (v + 4) / 8;
const g = v01 * v01 * (0.25 + 0.75 * v01);
data[i] = night[0] + (acid[0] - night[0]) * g;
data[i + 1] = night[1] + (acid[1] - night[1]) * g;
data[i + 2] = night[2] + (acid[2] - night[2]) * g;
data[i + 3] = 255;
i += 4;
}
}
lowCtx.putImageData(image, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(low, 0, 0, lowW, lowH, 0, 0, width, height);
const cost = performance.now() - started;
if (cost > FRAME_BUDGET_MS && downscale < MAX_DOWNSCALE) {
downscale += 1;
calmFrames = 0;
sizeLow();
} else if (cost < FRAME_BUDGET_MS * 0.35 && downscale > BASE_DOWNSCALE) {
calmFrames += 1;
if (calmFrames > 120) {
downscale -= 1;
calmFrames = 0;
sizeLow();
}
}
};
const tick = (now: number) => {
if (!running) return;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += dt * speed;
render();
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);
width = Math.max(1, Math.round(rect.width * dpr));
height = Math.max(1, Math.round(rect.height * dpr));
canvas.width = width;
canvas.height = height;
downscale = BASE_DOWNSCALE;
sizeLow();
if (reduced) {
// Freeze on a composed frame; no loop.
elapsed = 9;
render();
}
};
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);
};
}, [speed, scale, 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-plasma"
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
<span className="sr-only">
A retro plasma interference pattern in the Pax night and acid colors.
</span>
<div className="relative z-[1]">{children}</div>
</div>
);
}