# Strand — WebGL Background for React

> 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. Idles on its own when untouched — a ghost cursor parts the threads.

- Page: https://crucibleui.com/components/backgrounds/strand
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: ogl
- npm dependencies: ogl

## Install

```bash
npx shadcn@latest add @crucible/strand
```

Registry JSON: https://crucibleui.com/r/strand.json

Import from `@/components/crucible/strand`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`StrandProps`)

| 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. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no real pointer over the field, a seeded ghost cursor drifts through it on a slow Lissajous path, parting the threads at a softer strength, so it never looks dead in thumbnails and heroes. The real pointer takes over instantly. Only when `interactive`; never under reduced motion. |
| `paused?` | `boolean` | `false` | Freeze the animation, holding the current frame. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Strand } from "@/components/crucible/strand";
import { DemoScene } from "@/components/registry/demo-scene";

export default function StrandDemo() {
  return (
    <DemoScene
      background={<Strand />}
      headline="Strung from edge to edge"
      subhead="Silver threads drift on the loom and part around your cursor."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`strand.tsx`)

```tsx
"use client";

import * as React from "react";
import { Mesh, Program, Renderer, Triangle } from "ogl";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";
import { clampDpr, useDprCap } from "@/hooks/use-dpr-cap";

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;
  /**
   * Idle autoplay: after ~1.5s with no real pointer over the field, a seeded
   * ghost cursor drifts through it on a slow Lissajous path, parting the
   * threads at a softer strength, so it never looks dead in thumbnails and
   * heroes. The real pointer takes over instantly. Only when `interactive`;
   * never under reduced motion. @default true
   */
  idle?: boolean;
  /** 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;

/** Ghost takes over this long (ms) after the last real pointer activity. */
const GHOST_DELAY_MS = 1500;
/** Ghost bend strength relative to a real pointer. */
const GHOST_PRESENCE = 0.75;

/** 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,
  idle = true,
  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;
  const idleRef = React.useRef(idle);
  idleRef.current = idle;
  // performance.now() of the last real pointer activity; -Infinity = never.
  const lastRealPointerRef = React.useRef(-Infinity);
  const insideRef = React.useRef(false);

  // 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;

    // useVisibilityPause restarts `elapsed` at 0 whenever its `paused` option
    // flips; re-seat so the first frame after a resume steps by 0, not backwards.
    if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;
    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

    // Idle ghost: a slow seeded Lissajous cursor while nobody is over the field.
    const pt = pointerTargetRef.current;
    if (
      interactiveRef.current &&
      idleRef.current &&
      !insideRef.current &&
      performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS
    ) {
      const gt = elapsed + it.seed;
      pt[0] = 0.5 + 0.34 * Math.sin(gt * 0.27) * Math.cos(gt * 0.083);
      pt[1] = 0.5 + 0.22 * Math.sin(gt * 0.19 + 1.3);
      influenceTargetRef.current = GHOST_PRESENCE;
    }

    // Frame-rate-independent pointer damping (exponential time constant).
    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,
  });

  // Host-imposed DPR ceiling (galleries of thumbnails cap fill rate).
  const dprCap = useDprCap();

  // ---- 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 = clampDpr(dprCap, 2);

    let renderer: Renderer;
    let gl: Renderer["gl"];
    try {
      renderer = new Renderer({
        canvas,
        dpr: baseDpr,
        alpha: true,
        antialias: false, // fullscreen fragment pass — MSAA buys nothing
        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;
      insideRef.current = true;
      lastRealPointerRef.current = performance.now();
    };
    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;
      insideRef.current = false;
      // The ghost resumes after the usual delay rather than snapping in.
      lastRealPointerRef.current = performance.now();
    };

    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
  }, [dprCap]);

  // ---- 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>
  );
}
```
