Strand
A converging thread field: silver filaments strung between two off-screen loom anchors — ends pinned, free middles bowing under noise like threads at uneven tension. Each thread is a 1/|d| neon glow with a white-hot core and long silver falloff, depth-faded into a dim far weave; the cursor parts the field with a Gaussian bend that eases in and cascades from near threads to far. Original OGL fragment shader, DPR-clamped with a 1920px render cap, offscreen-paused, reduced-motion-safe.
oglfree
"use client";
import * as React from "react";
import { Mesh, Program, Renderer, Triangle } from "ogl";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface StrandProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Two tones: `[core, tail]`. `core` is the white-hot 1px filament center,
* `tail` is the silver its glow cools into. Any hex string.
* @default ["#fafafa", "#a3a3a3"]
*/
colors?: [string, string];
/**
* Number of threads, clamped 2–64. A real uniform-driven prop — changing it
* live-updates the shader without ever rebuilding the GL context.
* @default 32
*/
lineCount?: number;
/**
* How far the free middles bow and wander under noise. 1 = default drift,
* 0 = perfectly taut threads. @default 1
*/
amplitude?: number;
/**
* Vertical fan of the thread field as a fraction of the element height,
* clamped 0–1.5. @default 1
*/
spread?: number;
/**
* How strongly both ends of every thread pinch toward the two off-screen
* loom anchors: 0 = parallel threads, 1 = full pinch. @default 0.6
*/
convergence?: number;
/** Drift speed multiplier. 1 = default. @default 1 */
speed?: number;
/** Overall glow brightness. 1 = default. @default 1 */
intensity?: number;
/**
* Threads part around the cursor with a Gaussian bend that cascades from
* near threads to far ones. @default true
*/
interactive?: boolean;
/** Radius of the cursor bend, as a fraction of the element height. @default 0.3 */
bendRadius?: number;
/** How far threads part around the cursor. 0 disables the bend. @default 1 */
bendStrength?: number;
/** Freeze the animation, holding the current frame. @default false */
paused?: boolean;
}
const DEFAULT_COLORS: [string, string] = [
"#fafafa", // white — hot filament core
"#a3a3a3", // neutral-400 silver — glow tail
];
/** Hex → [r,g,b] in 0–1. Accepts #rgb / #rrggbb. */
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [0.98, 0.98, 0.98];
return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255];
}
/**
* Original converging-thread-field fragment shader. N filaments are evaluated
* analytically per pixel: each thread's ends pin toward two off-screen loom
* anchors (left and right) while its free middle bows and wanders under one
* value-noise eval plus a cheap trig detail term riding on it — threads under
* uneven tension. Rendering is a 1/|d| neon profile per thread (white-hot
* ~1px core, long soft silver falloff — the glow doubles as free bloom),
* accumulated and squashed through an exponential tone curve so crossings
* bloom instead of clipping. The cursor parts the field with a Gaussian bend
* gated by an eased 0→1 influence scalar, staggered per line so near threads
* respond first and the release cascades back out. Grain dithering defeats
* 8-bit banding in the glow. GLSL ES 1.00 (WebGL1/2 compatible).
*/
const FRAGMENT = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uPointer; // eased pointer, 0..1, y-up
uniform float uSeed; // per-instance time offset — desyncs multiple instances
uniform float uInfluence; // eased 0..1 cursor-influence scalar
uniform vec3 uColorCore; // white-hot filament center
uniform vec3 uColorTail; // silver glow tail
uniform float uLineCount;
uniform float uAmplitude;
uniform float uSpread;
uniform float uConvergence;
uniform float uSpeed;
uniform float uIntensity;
uniform float uBendRadius;
uniform float uBendStrength;
const int MAX_LINES = 64;
const float PI = 3.14159265359;
float hash11(float n) {
return fract(sin(n * 127.1 + 0.7) * 43758.5453123);
}
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void main() {
vec2 uv = vUv;
float aspect = uResolution.x / max(uResolution.y, 1.0);
float t = (uTime + uSeed) * uSpeed;
float count = clamp(uLineCount, 2.0, float(MAX_LINES));
// Loom anchors sit beyond both screen edges; tx is the position along the
// extended span between them. pinch peaks mid-screen and shrinks toward the
// anchors, so at convergence 1 the fan pinches fully just off-screen.
float margin = 0.22;
float tx = (uv.x + margin) / (1.0 + 2.0 * margin);
float pinch = sin(PI * tx);
float profile = mix(1.0, pinch, clamp(uConvergence, 0.0, 1.0));
// Wander envelope: anchored ends, free middles — even at convergence 0 the
// threads stay pinned at the loom and billow through the center.
float freeMid = pow(pinch, 0.85);
float bend2 = max(uBendRadius * uBendRadius, 1e-4);
// Hot-core width in buffer pixels, gently tracking element size.
float coreW = clamp(uResolution.y / 900.0, 0.75, 1.6);
vec3 acc = vec3(0.0);
for (int i = 0; i < MAX_LINES; i++) {
if (float(i) >= count) break;
float fi = float(i);
float h1 = hash11(fi * 1.618 + 0.71);
float h2 = hash11(fi * 2.317 + 4.30);
float h3 = hash11(fi * 3.113 + 9.20);
// Lane (evenly spaced, seeded jitter) and shuffled depth so near and far
// threads interleave instead of sorting top-to-bottom.
float lane = (fi + 0.5) / count - 0.5 + (h1 - 0.5) * (0.5 / count);
float depth = h2; // 0 near .. 1 far
// Per-line clock: each thread drifts at its own rate and phase.
float lt = t * mix(0.7, 1.25, h3) + h1 * 43.0;
// Wander: ONE noise eval per line, plus a cheap trig detail term whose
// phase is modulated by the noise so it never reads as a comb.
float n = vnoise(vec2(uv.x * aspect * 1.35 + h1 * 89.0, lt * 0.11)) - 0.5;
float detail = sin(uv.x * aspect * 9.0 + h2 * 61.0 + lt * 0.35 + n * 5.0);
float amp = uAmplitude * 0.16 * freeMid * mix(1.0, 0.5, depth) * mix(0.75, 1.2, h3);
float yc = 0.5 + lane * clamp(uSpread, 0.0, 1.5) * 0.96 * profile
+ (n * 2.0 + detail * 0.22) * amp;
// Cursor bend: Gaussian part, gated by the eased influence scalar with a
// per-line stagger — threads nearer the cursor's lane engage first, and on
// leave the release cascades back out through the field.
float stagger = clamp(abs(yc - uPointer.y) * 1.5, 0.0, 0.7);
float lineInf = smoothstep(stagger, stagger + 0.3, uInfluence);
float dx = (uv.x - uPointer.x) * aspect;
float dy = yc - uPointer.y;
float gauss = exp(-(dx * dx + dy * dy) / bend2);
yc += uBendStrength * lineInf * dy * gauss * 1.7;
// 1/|d| neon glow: hot ~1px core with a long soft falloff — the falloff
// IS the bloom, no extra passes.
float dpx = abs(uv.y - yc) * uResolution.y;
float w = mix(1.8, 1.0, depth) * coreW;
float g = w / (dpx + w);
// Depth-faded silver with a slow shimmer breathing along each thread, and
// a gentle lift where the cursor holds the field open.
float bright = mix(1.0, 0.15, depth);
bright *= 0.82 + 0.18 * sin(lt * 0.9 + uv.x * aspect * 6.0 + h2 * 20.0);
bright *= 1.0 + 0.6 * lineInf * gauss;
acc += mix(uColorTail, uColorCore, pow(g, 3.0)) * (g * bright);
}
// Squash the accumulated glow — crossings bloom and roll off, never clip.
vec3 threads = 1.0 - exp(-acc * 1.25 * uIntensity);
// Near-black plate with a faint cool lift toward the base.
vec3 col = mix(vec3(0.010, 0.011, 0.014), vec3(0.026, 0.028, 0.034),
pow(1.0 - uv.y, 1.5));
col += threads;
// Vignette sinks the frame edges; grain dither defeats 8-bit banding.
vec2 vg = (uv - 0.5) * vec2(aspect, 1.0);
col *= 1.0 - 0.30 * dot(vg, vg);
float grain = hash21(gl_FragCoord.xy + fract(t) * 137.0) - 0.5;
col += grain * (1.8 / 255.0);
gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`;
const VERTEX = /* glsl */ `
precision highp float;
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
interface Uniform<T> {
value: T;
}
interface Internals {
renderer: Renderer;
mesh: Mesh;
program: Program;
uTime: Uniform<number>;
uSeed: Uniform<number>;
uInfluence: Uniform<number>;
uResolution: Uniform<[number, number]>;
uPointer: Uniform<[number, number]>;
seed: number;
loseContext: (() => void) | null;
}
/** OGL early-returns on a failed link without creating uniformLocations. */
function programLinked(program: Program): boolean {
return (program as unknown as { uniformLocations?: unknown }).uniformLocations !== undefined;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
/** Longest edge of the drawing buffer — larger elements upscale imperceptibly. */
const MAX_RENDER_DIMENSION = 1920;
/** uTime for the reduced-motion still: threads gracefully bowed mid-drift. */
const REDUCED_TIME = 6.0;
/** Deterministic per-instance time offset so multiple instances desynchronize. */
let instanceCounter = 0;
/**
* Strand — a converging thread field. Silver filaments strung between two
* off-screen loom anchors: ends pinned, free middles bowing and wandering
* under noise like threads held at uneven tension. Each thread renders as a
* 1/|d| neon glow — white-hot 1px core, long soft silver falloff — depth-faded
* so near threads burn bright over a dim far weave. The cursor parts the
* field with a Gaussian bend that eases in and out (never pops) and cascades
* from near threads to far ones.
*
* Original OGL fragment shader on a fullscreen triangle: mounted once with
* every knob a live uniform, DPR ≤2 with a 1920px max-render-dimension cap,
* offscreen- and hidden-tab-paused, reduced-motion-safe (a frozen mid-drift
* still), full WebGL teardown on unmount, fails soft on lost context. Drop it
* inside any `relative` container; it fills and sits behind content.
*/
export function Strand({
colors = DEFAULT_COLORS,
lineCount = 32,
amplitude = 1,
spread = 1,
convergence = 0.6,
speed = 1,
intensity = 1,
interactive = true,
bendRadius = 0.3,
bendStrength = 1,
paused = false,
className,
...props
}: StrandProps) {
const internalsRef = React.useRef<Internals | null>(null);
const [ready, setReady] = React.useState(false);
const [failed, setFailed] = React.useState(false);
const reducedMotion = useReducedMotion();
// Live mirrors read inside the RAF loop without re-subscribing it.
const interactiveRef = React.useRef(interactive);
interactiveRef.current = interactive;
// Pointer + influence integration state.
const pointerRef = React.useRef<[number, number]>([0.5, 0.5]);
const pointerTargetRef = React.useRef<[number, number]>([0.5, 0.5]);
const influenceRef = React.useRef(0);
const influenceTargetRef = React.useRef(0);
const lastElapsedRef = React.useRef(0);
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it || !programLinked(it.program)) return;
let dt = elapsed - lastElapsedRef.current;
lastElapsedRef.current = elapsed;
if (dt < 0) dt = 0;
if (dt > 0.05) dt = 0.05; // clamp jumps after a pause/tab-switch
// Frame-rate-independent pointer damping (exponential time constant).
const pt = pointerTargetRef.current;
const pc = pointerRef.current;
const pe = 1 - Math.exp(-7 * dt);
pc[0] += (pt[0] - pc[0]) * pe;
pc[1] += (pt[1] - pc[1]) * pe;
// Eased 0→1 influence scalar — quicker to engage than to let go, so the
// bend fades in and out instead of popping.
const target = interactiveRef.current ? influenceTargetRef.current : 0;
const cur = influenceRef.current;
const k = target > cur ? 5 : 2.4;
influenceRef.current = cur + (target - cur) * (1 - Math.exp(-k * dt));
it.uTime.value = elapsed;
it.uInfluence.value = influenceRef.current;
it.uPointer.value[0] = pc[0];
it.uPointer.value[1] = pc[1];
it.renderer.render({ scene: it.mesh });
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, {
paused: paused || reducedMotion,
});
// ---- setup / teardown (mount once; every knob is a live uniform) ----
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Fresh canvas per mount: a canvas whose context was killed via
// WEBGL_lose_context (our teardown) can only ever return that dead
// context again — StrictMode's double-mount would come back black.
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.className = "absolute inset-0 block h-full w-full";
container.appendChild(canvas);
const baseDpr = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
2
);
let renderer: Renderer;
let gl: Renderer["gl"];
try {
renderer = new Renderer({
canvas,
dpr: baseDpr,
alpha: true,
antialias: true,
premultipliedAlpha: false,
});
gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
} catch (err) {
console.warn("[crucible] WebGL unavailable — rendering static fallback.", err);
canvas.remove();
setFailed(true);
return;
}
const onContextLost = (e: Event) => {
e.preventDefault();
setFailed(true);
};
canvas.addEventListener("webglcontextlost", onContextLost);
const seed = (instanceCounter++ % 64) * 5.3;
const uTime: Uniform<number> = { value: 0 };
const uSeed: Uniform<number> = { value: seed };
const uInfluence: Uniform<number> = { value: 0 };
const uResolution: Uniform<[number, number]> = {
value: [gl.drawingBufferWidth || 1, gl.drawingBufferHeight || 1],
};
const uPointer: Uniform<[number, number]> = { value: [0.5, 0.5] };
const program = new Program(gl, {
vertex: VERTEX,
fragment: FRAGMENT,
uniforms: {
uTime,
uSeed,
uInfluence,
uResolution,
uPointer,
uColorCore: { value: hexToRgb(colors[0]) },
uColorTail: { value: hexToRgb(colors[1]) },
uLineCount: { value: clamp(Math.round(lineCount), 2, 64) },
uAmplitude: { value: Math.max(amplitude, 0) },
uSpread: { value: clamp(spread, 0, 1.5) },
uConvergence: { value: clamp(convergence, 0, 1) },
uSpeed: { value: Math.max(speed, 0) },
uIntensity: { value: Math.max(intensity, 0) },
uBendRadius: { value: clamp(bendRadius, 0.05, 1) },
uBendStrength: { value: Math.max(bendStrength, 0) },
},
});
const geometry = new Triangle(gl);
const mesh = new Mesh(gl, { geometry, program });
const loseExt = gl.getExtension("WEBGL_lose_context");
const internals: Internals = {
renderer,
mesh,
program,
uTime,
uSeed,
uInfluence,
uResolution,
uPointer,
seed,
loseContext: loseExt ? () => loseExt.loseContext() : null,
};
internalsRef.current = internals;
const resize = () => {
const { clientWidth: w, clientHeight: h } = container;
// DPR ≤2 plus a max-render-dimension cap: huge elements render at most
// 1920px on their longest edge and upscale imperceptibly.
const longest = Math.max(w, h, 1) * baseDpr;
renderer.dpr =
longest > MAX_RENDER_DIMENSION
? baseDpr * (MAX_RENDER_DIMENSION / longest)
: baseDpr;
renderer.setSize(Math.max(w, 1), Math.max(h, 1));
uResolution.value[0] = gl.drawingBufferWidth;
uResolution.value[1] = gl.drawingBufferHeight;
// Keep the frame correct even while the loop is idle (paused/reduced).
if (programLinked(program)) renderer.render({ scene: mesh });
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
const onPointerMove = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
pointerTargetRef.current[0] = (e.clientX - rect.left) / rect.width;
// Flip Y so "up" on screen is 1.
pointerTargetRef.current[1] = 1 - (e.clientY - rect.top) / rect.height;
influenceTargetRef.current = 1;
};
const onPointerLeave = () => {
// Keep the last pointer position — the influence scalar fades the bend
// out in place instead of dragging threads back through center.
influenceTargetRef.current = 0;
};
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("pointerleave", onPointerLeave);
container.addEventListener("pointercancel", onPointerLeave);
setReady(true);
return () => {
setReady(false);
internalsRef.current = null;
ro.disconnect();
canvas.removeEventListener("webglcontextlost", onContextLost);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerleave", onPointerLeave);
container.removeEventListener("pointercancel", onPointerLeave);
internals.loseContext?.();
canvas.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- live-sync knob uniforms in place; re-render if the loop is idle ----
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !programLinked(it.program)) return;
const u = it.program.uniforms;
u.uColorCore.value = hexToRgb(colors[0]);
u.uColorTail.value = hexToRgb(colors[1]);
u.uLineCount.value = clamp(Math.round(lineCount), 2, 64);
u.uAmplitude.value = Math.max(amplitude, 0);
u.uSpread.value = clamp(spread, 0, 1.5);
u.uConvergence.value = clamp(convergence, 0, 1);
u.uSpeed.value = Math.max(speed, 0);
u.uIntensity.value = Math.max(intensity, 0);
u.uBendRadius.value = clamp(bendRadius, 0.05, 1);
u.uBendStrength.value = Math.max(bendStrength, 0);
if (paused || reducedMotion) it.renderer.render({ scene: it.mesh });
});
// ---- designed still under reduced motion; hold frame when paused ----
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !ready || !programLinked(it.program)) return;
if (reducedMotion) {
// A composed mid-drift frame: threads gracefully bowed, interaction off.
// Seed zeroed so the still is the same designed frame on every instance.
it.uTime.value = REDUCED_TIME;
it.uSeed.value = 0;
it.uInfluence.value = 0;
it.uPointer.value[0] = 0.5;
it.uPointer.value[1] = 0.5;
influenceRef.current = 0;
influenceTargetRef.current = 0;
it.renderer.render({ scene: it.mesh });
} else {
it.uSeed.value = it.seed;
if (paused) it.renderer.render({ scene: it.mesh });
}
}, [reducedMotion, paused, ready]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="strand"
className={cn(
"absolute inset-0 -z-10 block overflow-hidden bg-neutral-950",
className
)}
{...props}
>
{failed && (
<div
aria-hidden
className="absolute inset-0"
style={{
background:
"linear-gradient(178deg, transparent 44%, rgba(250,250,250,0.10) 49%, transparent 50%, transparent 58%, rgba(163,163,163,0.08) 63%, transparent 64%)",
}}
/>
)}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/strandManual — install dependencies, then copy the source
npm install oglProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | [string, string] | ["#fafafa", "#a3a3a3"] | Two tones: [core, tail]. core is the white-hot 1px filament center, tail is the silver its glow cools into. Any hex string. |
| lineCount | number | 32 | Number of threads, clamped 2–64. A real uniform-driven prop — changing it live-updates the shader without ever rebuilding the GL context. |
| amplitude | number | 1 | How far the free middles bow and wander under noise. 1 = default drift, 0 = perfectly taut threads. |
| spread | number | 1 | Vertical fan of the thread field as a fraction of the element height, clamped 0–1.5. |
| convergence | number | 0.6 | How strongly both ends of every thread pinch toward the two off-screen loom anchors: 0 = parallel threads, 1 = full pinch. |
| speed | number | 1 | Drift speed multiplier. 1 = default. |
| intensity | number | 1 | Overall glow brightness. 1 = default. |
| interactive | boolean | true | Threads part around the cursor with a Gaussian bend that cascades from near threads to far ones. |
| bendRadius | number | 0.3 | Radius of the cursor bend, as a fraction of the element height. |
| bendStrength | number | 1 | How far threads part around the cursor. 0 disables the bend. |
| paused | boolean | false | Freeze the animation, holding the current frame. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.