# Aurora

> Northern-lights curtains with drifting vertical rays and a twinkling sky. The nearest curtain leans toward the pointer and glows brighter where you hover.

Category: Backgrounds / Shader · tags: webgl, pointer, noise

## Install

```bash
npx shadcn@latest add https://opusui.131199999.xyz/r/aurora.json
# or offline from an opus55 checkout:
node <path-to>/opus55/scripts/add.mjs aurora
```

npm dependencies: clsx, tailwind-merge

Requires `components/opus/opus.css` imported once after `@import "tailwindcss"`.

## Usage

```tsx
import { Aurora } from "@/components/opus/backgrounds/aurora";

<section className="relative h-[80vh]">
  <Aurora className="absolute inset-0" />
  <div className="relative z-10">…your content</div>
</section>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| colorA | `string` | `"#2af5a4"` | Lower edge of the curtains (hex). |
| colorB | `string` | `"#7c5cff"` | Upper glow of the curtains (hex). |
| colorC | `string` | `"#22d3ee"` | Third curtain + pointer glow (hex). |
| background | `string` | `"#050510"` | Sky colour (hex). |
| intensity | `number` | `1` | Overall brightness, 0.2–2. |
| height | `number` | `1` | How tall the curtains reach, 0.4–2. |
| stars | `boolean` | `true` | Twinkling stars behind the aurora. |
| speed | `number` | `1` | Time multiplier. 0 freezes the animation; the pointer stays live. |
| interactive | `boolean` | `true` | React to the pointer (tracked on window, so overlaid content never blocks it). |
| className | `string` | — | Classes for the wrapper. Use `absolute inset-0` to fill a relative parent. |
| children | `ReactNode` | — | Optional content rendered above the canvas. |

## Notes

- Renders with WebGL2 into a single full-screen triangle — no three.js, no textures, no dependencies.
- Pauses automatically when scrolled off-screen or when the tab is hidden; resumes on return.
- `prefers-reduced-motion: reduce` renders one still frame and ignores the pointer.
- A CSS gradient fallback paints instantly and stays if WebGL2 is unavailable.

## Source

### components/opus/backgrounds/aurora.tsx

```tsx
"use client";

import type { CSSProperties, ReactNode } from "react";
import { GLSL_HASH, GLSL_SIMPLEX, GLSL_UTILS } from "../lib/glsl";
import { ShaderCanvas } from "../lib/shader-canvas";

export type AuroraProps = {
  /** Lower edge of the curtains. */
  colorA?: string;
  /** Upper glow of the curtains. */
  colorB?: string;
  /** Accent used by the third curtain and the pointer glow. */
  colorC?: string;
  /** Night-sky colour behind everything. */
  background?: string;
  speed?: number;
  /** Overall brightness, 0.2..2. */
  intensity?: number;
  /** How tall the curtains reach, 0.4..2. */
  height?: number;
  /** Twinkling stars behind the aurora. */
  stars?: boolean;
  /** Curtains bend toward the pointer. */
  interactive?: boolean;
  className?: string;
  style?: CSSProperties;
  children?: ReactNode;
};

const FRAG = /* glsl */ `
${GLSL_HASH}
${GLSL_SIMPLEX}
${GLSL_UTILS}

void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  float ar = uResolution.x / uResolution.y;
  vec2 p = vec2(uv.x * ar, uv.y);
  vec2 m = vec2(uPointer.x * ar, uPointer.y);
  float t = uTime * 0.22;

  vec3 col = uBackground * (0.7 + 0.5 * uv.y);
  vec3 acc = vec3(0.0);

  for (int i = 0; i < 3; i++) {
    float fi = float(i);
    float x = p.x * 0.55 + fi * 3.7;
    float base = 0.36 + fi * 0.1
      + 0.17 * snoise(vec2(x * 0.9 + t * 0.45, fi * 7.0 + t * 0.12))
      + 0.05 * snoise(vec2(x * 2.6 - t * 0.7, fi * 3.0));

    // the curtain leans toward the pointer
    float dx = p.x - m.x;
    float pull = exp(-dx * dx * 5.0) * uPointerActive;
    base += (m.y - 0.08 - base) * 0.45 * pull;

    float d = uv.y - base;
    float falloff = (3.2 + fi * 1.6) / uHeight;
    float curtain = smoothstep(-0.035, 0.012, d) * exp(-max(d, 0.0) * falloff);

    // vertical rays drifting sideways
    float warp = snoise(vec2(x * 1.7, t * 0.8 + fi)) * 0.5;
    float rays = 0.5 + 0.5 * snoise(vec2(x * 11.0 + warp, t * 1.4 + fi * 5.0));
    rays = pow(rays, 1.6);

    vec3 c = mix(uColorA, uColorB, smoothstep(0.0, 0.35, d));
    c = mix(c, uColorC, fi * 0.33);
    acc += c * curtain * (0.25 + 1.1 * rays) * (1.0 - fi * 0.2) * (1.0 + pull * 0.8);
  }

  float pd = length(p - m);
  acc += uColorC * exp(-pd * pd * 14.0) * 0.18 * uPointerActive;
  col += acc * uIntensity;

  if (uStars > 0.5) {
    vec2 sp = p * 160.0;
    vec2 cell = floor(sp);
    float s = hash12(cell);
    float tw = 0.55 + 0.45 * sin(uTime * 1.7 + s * 80.0);
    float star = step(0.9965, s) * smoothstep(0.45, 0.0, length(fract(sp) - 0.5)) * smoothstep(0.15, 0.9, uv.y);
    col += vec3(star * tw * 0.8);
  }

  col = 1.0 - exp(-col * 1.35);
  fragColor = vec4(dither(col), 1.0);
}
`;

export function Aurora({
  colorA = "#2af5a4",
  colorB = "#7c5cff",
  colorC = "#22d3ee",
  background = "#050510",
  speed = 1,
  intensity = 1,
  height = 1,
  stars = true,
  interactive = true,
  className,
  style,
  children,
}: AuroraProps) {
  return (
    <ShaderCanvas
      fragment={FRAG}
      uniforms={{
        uColorA: colorA,
        uColorB: colorB,
        uColorC: colorC,
        uBackground: background,
        uIntensity: intensity,
        uHeight: height,
        uStars: stars,
      }}
      speed={speed}
      interactive={interactive}
      pointerEase={0.05}
      fallback={`radial-gradient(120% 70% at 50% 100%, ${colorA}33, transparent 60%), ${background}`}
      className={className}
      style={style}
    >
      {children}
    </ShaderCanvas>
  );
}
```

### components/opus/lib/cn.ts

```tsx
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/** Merge class names; later Tailwind classes win over earlier ones. */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### components/opus/lib/color.ts

```tsx
/** "#7c5cff" | "#75f" → [r, g, b] in 0..1 (sRGB). Unknown input → mid grey. */
export function hexToRgb01(hex: string): [number, number, number] {
  let h = hex.trim().replace(/^#/, "");
  if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split("").map((c) => c + c).join("");
  if (!/^[0-9a-fA-F]{6}/.test(h)) return [0.5, 0.5, 0.5];
  const n = parseInt(h.slice(0, 6), 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

/** "#7c5cff", 0.4 → "rgba(124, 92, 255, 0.4)" */
export function hexToRgba(hex: string, alpha: number): string {
  const [r, g, b] = hexToRgb01(hex);
  return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`;
}
```

### components/opus/lib/glsl.ts

```tsx
/**
 * Small GLSL ES 3.00 chunks shared by OPUS shader backgrounds.
 * Concatenate the ones you need in front of `void main()`.
 */

/** hash12 / hash22 / hash33 — cheap stable hashes (Dave Hoskins, "Hash without Sine", MIT). */
export const GLSL_HASH = /* glsl */ `
float hash12(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
vec2 hash22(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.xx + p3.yz) * p3.zy);
}
vec3 hash33(vec3 p3) {
  p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yxz + 33.33);
  return fract((p3.xxy + p3.yxx) * p3.zyx);
}
`;

/** snoise(vec2) / snoise(vec3) — simplex noise in [-1, 1] (Ashima Arts / Ian McEwan, MIT). */
export const GLSL_SIMPLEX = /* glsl */ `
vec3 _sm289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 _sm289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 _sm289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 _sperm(vec3 x) { return _sm289(((x * 34.0) + 10.0) * x); }
vec4 _sperm(vec4 x) { return _sm289(((x * 34.0) + 10.0) * x); }
vec4 _staylor(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }

float snoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = _sm289(i);
  vec3 p = _sperm(_sperm(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
  m = m * m; m = m * m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
  vec3 g;
  g.x = a0.x * x0.x + h.x * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

float snoise(vec3 v) {
  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i = floor(v + dot(v, C.yyy));
  vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz);
  vec3 l = 1.0 - g;
  vec3 i1 = min(g.xyz, l.zxy);
  vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + C.xxx;
  vec3 x2 = x0 - i2 + C.yyy;
  vec3 x3 = x0 - D.yyy;
  i = _sm289(i);
  vec4 p = _sperm(_sperm(_sperm(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 0.142857142857;
  vec3 ns = n_ * D.wyz - D.xzx;
  vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
  vec4 x_ = floor(j * ns.z);
  vec4 y_ = floor(j - 7.0 * x_);
  vec4 x = x_ * ns.x + ns.yyyy;
  vec4 y = y_ * ns.x + ns.yyyy;
  vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy);
  vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0) * 2.0 + 1.0;
  vec4 s1 = floor(b1) * 2.0 + 1.0;
  vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
  vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
  vec3 p0 = vec3(a0.xy, h.x);
  vec3 p1 = vec3(a0.zw, h.y);
  vec3 p2 = vec3(a1.xy, h.z);
  vec3 p3 = vec3(a1.zw, h.w);
  vec4 norm = _staylor(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
  m = m * m;
  return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
`;

/** fbm(vec2) / fbm(vec3) — 5-octave fractal simplex in ~[-1, 1]. Requires GLSL_SIMPLEX. */
export const GLSL_FBM = /* glsl */ `
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  mat2 r = mat2(0.8, 0.6, -0.6, 0.8);
  for (int i = 0; i < 5; i++) { v += a * snoise(p); p = r * p * 2.02 + 17.0; a *= 0.5; }
  return v;
}
float fbm(vec3 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 5; i++) { v += a * snoise(p); p = p * 2.02 + 17.0; a *= 0.5; }
  return v;
}
`;

/** Helpers: rot2, IQ cosine palette, screen-space dither (kills gradient banding), soft vignette. */
export const GLSL_UTILS = /* glsl */ `
mat2 rot2(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
vec3 cosPalette(float t, vec3 a, vec3 b, vec3 c, vec3 d) { return a + b * cos(6.28318 * (c * t + d)); }
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
vec3 dither(vec3 c) { return c + (ign(gl_FragCoord.xy) - 0.5) / 255.0; }
float vignette(vec2 uv, float k) { uv = uv * 2.0 - 1.0; return clamp(1.0 - dot(uv, uv) * k, 0.0, 1.0); }
vec2 aspectUv(vec2 uv) { return (uv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0); }
`;
```

### components/opus/lib/shader-canvas.tsx

```tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { cn } from "./cn";
import { hexToRgb01 } from "./color";
import { prefersReducedMotion } from "./use-reduced-motion";

/**
 * ShaderCanvas — a zero-dependency WebGL2 fragment-shader surface.
 *
 * You write only `void main()`; these uniforms are declared for you:
 *
 *   uniform vec2  uResolution;      // drawing-buffer size in px
 *   uniform float uTime;            // seconds × speed (frozen under reduced motion)
 *   uniform vec2  uPointer;         // 0..1, eased, origin bottom-left
 *   uniform vec2  uPointerVelocity; // eased velocity in uv / second
 *   uniform float uPointerActive;   // 0..1, eased "pointer is over the element"
 *   uniform vec4  uClick;           // xy = last click (0..1), z = seconds since click, w = click count
 *   uniform float uDpr;             // drawing-buffer px per CSS px (size pixel patterns with it)
 *   out vec4 fragColor;
 *
 * Every key of `uniforms` is declared too: number|boolean → float, "#hex" → vec3,
 * [a,b] → vec2, [a,b,c] → vec3, [a,b,c,d] → vec4. Values update without recompiling.
 *
 * The pointer is tracked on `window`, so content layered above the canvas
 * does not block the interaction. Rendering pauses off-screen and in hidden tabs.
 */

export type UniformValue = number | boolean | string | readonly number[];

export type ShaderCanvasProps = {
  /** GLSL ES 3.00 body containing `void main()`. */
  fragment: string;
  uniforms?: Record<string, UniformValue>;
  /** Time multiplier. 0 freezes the animation but keeps the pointer live. */
  speed?: number;
  /** React to the pointer. Default true. */
  interactive?: boolean;
  /** Pointer easing per 60fps frame (0..1). Lower = floatier. Default 0.08. */
  pointerEase?: number;
  /** Max device-pixel-ratio. Default 1.5 — shaders are fill-rate bound. */
  dpr?: number;
  /** Render scale 0.2..1 for heavy shaders (upscaled by CSS). Default 1. */
  resolution?: number;
  /** Stop the clock entirely. */
  paused?: boolean;
  /** CSS background painted until the first frame (and if WebGL2 is missing). */
  fallback?: string;
  className?: string;
  style?: CSSProperties;
  /** Content rendered on top of the shader. */
  children?: ReactNode;
};

const VERTEX = `#version 300 es
void main() {
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;

function glslType(v: UniformValue): string {
  if (typeof v === "number" || typeof v === "boolean") return "float";
  if (typeof v === "string") return "vec3";
  return v.length === 2 ? "vec2" : v.length === 3 ? "vec3" : "vec4";
}

function buildFragment(body: string, uniforms: Record<string, UniformValue>): string {
  const custom = Object.entries(uniforms)
    .map(([k, v]) => `uniform ${glslType(v)} ${k};`)
    .join("\n");
  return `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uPointer;
uniform vec2 uPointerVelocity;
uniform float uPointerActive;
uniform vec4 uClick;
uniform float uDpr;
${custom}
out vec4 fragColor;
#line 1
${body}`;
}

function compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | null {
  const sh = gl.createShader(type);
  if (!sh) return null;
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
    console.error("[ShaderCanvas] compile error:\n" + gl.getShaderInfoLog(sh));
    gl.deleteShader(sh);
    return null;
  }
  return sh;
}

export function ShaderCanvas({
  fragment,
  uniforms = {},
  speed = 1,
  interactive = true,
  pointerEase = 0.08,
  dpr = 1.5,
  resolution = 1,
  paused = false,
  fallback,
  className,
  style,
  children,
}: ShaderCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [ready, setReady] = useState(false);

  // Live values read by the render loop — updating them never re-creates the GL context.
  const live = useRef({ uniforms, speed, interactive, pointerEase, paused });
  live.current = { uniforms, speed, interactive, pointerEase, paused };
  const redraw = useRef<() => void>(() => {});

  // The uniform *signature* (names + types) is part of the program source.
  const signature = Object.entries(uniforms)
    .map(([k, v]) => `${k}:${glslType(v)}`)
    .join(",");

  useEffect(() => {
    redraw.current();
  });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const gl = canvas.getContext("webgl2", {
      antialias: false,
      alpha: true,
      premultipliedAlpha: false,
      powerPreference: "high-performance",
    });
    if (!gl) return;

    const reduced = prefersReducedMotion();
    let program: WebGLProgram | null = null;
    let vao: WebGLVertexArrayObject | null = null;
    const locs = new Map<string, WebGLUniformLocation | null>();

    const init = () => {
      const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
      const fs = compile(gl, gl.FRAGMENT_SHADER, buildFragment(fragment, live.current.uniforms));
      if (!vs || !fs) return false;
      const p = gl.createProgram();
      if (!p) return false;
      gl.attachShader(p, vs);
      gl.attachShader(p, fs);
      gl.linkProgram(p);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
        console.error("[ShaderCanvas] link error:\n" + gl.getProgramInfoLog(p));
        return false;
      }
      program = p;
      vao = gl.createVertexArray();
      locs.clear();
      return true;
    };
    if (!init()) return;

    const loc = (name: string) => {
      if (!locs.has(name)) locs.set(name, program ? gl.getUniformLocation(program, name) : null);
      return locs.get(name) ?? null;
    };

    // ---- sizing ----
    let width = 1;
    let height = 1;
    let pxScale = 1;
    const resize = () => {
      const r = canvas.getBoundingClientRect();
      const scale = Math.min(window.devicePixelRatio || 1, dpr) * Math.min(1, Math.max(0.2, resolution));
      pxScale = scale;
      width = Math.max(1, Math.round(r.width * scale));
      height = Math.max(1, Math.round(r.height * scale));
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      requestFrame();
    };

    // ---- pointer (window-level so overlays don't block it) ----
    const pointer = { cx: 0, cy: 0, has: false, x: 0.5, y: 0.5, vx: 0, vy: 0, active: 0 };
    const click = { x: 0.5, y: 0.5, t: 1e3, n: 0 };
    const onMove = (e: PointerEvent) => {
      pointer.cx = e.clientX;
      pointer.cy = e.clientY;
      pointer.has = true;
      requestFrame();
    };
    const onDown = (e: PointerEvent) => {
      if (!live.current.interactive) return;
      const r = canvas.getBoundingClientRect();
      const x = (e.clientX - r.left) / r.width;
      const y = 1 - (e.clientY - r.top) / r.height;
      if (x < 0 || x > 1 || y < 0 || y > 1) return;
      click.x = x;
      click.y = y;
      click.t = 0;
      click.n += 1;
      requestFrame();
    };
    const onLeave = () => {
      pointer.has = false;
    };

    // ---- loop ----
    let raf = 0;
    let visible = true;
    let last = performance.now();
    let time = reduced ? 8 : 0;
    let first = true;

    const frame = (now: number) => {
      raf = 0;
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      const L = live.current;
      if (!program) return;

      if (!reduced && !L.paused) time += dt * L.speed;
      click.t += dt;

      // pointer → local uv
      let tx = 0.5;
      let ty = 0.5;
      let inside = false;
      if (L.interactive && pointer.has && !reduced) {
        const r = canvas.getBoundingClientRect();
        tx = (pointer.cx - r.left) / Math.max(1, r.width);
        ty = 1 - (pointer.cy - r.top) / Math.max(1, r.height);
        inside = tx >= 0 && tx <= 1 && ty >= 0 && ty <= 1;
        if (!inside) {
          // keep following just outside the box so edges feel continuous
          tx = Math.min(1.25, Math.max(-0.25, tx));
          ty = Math.min(1.25, Math.max(-0.25, ty));
        }
      }
      const k = 1 - Math.pow(1 - Math.min(0.99, Math.max(0.001, L.pointerEase)), dt * 60);
      const px = pointer.x;
      const py = pointer.y;
      pointer.x += (tx - pointer.x) * k;
      pointer.y += (ty - pointer.y) * k;
      const ivx = dt > 0 ? (pointer.x - px) / dt : 0;
      const ivy = dt > 0 ? (pointer.y - py) / dt : 0;
      pointer.vx += (ivx - pointer.vx) * 0.2;
      pointer.vy += (ivy - pointer.vy) * 0.2;
      pointer.active += ((inside ? 1 : 0) - pointer.active) * k * 0.7;

      gl.viewport(0, 0, width, height);
      gl.useProgram(program);
      gl.bindVertexArray(vao);
      gl.uniform2f(loc("uResolution"), width, height);
      gl.uniform1f(loc("uTime"), time);
      gl.uniform2f(loc("uPointer"), pointer.x, pointer.y);
      gl.uniform2f(loc("uPointerVelocity"), pointer.vx, pointer.vy);
      gl.uniform1f(loc("uPointerActive"), pointer.active);
      gl.uniform4f(loc("uClick"), click.x, click.y, click.t, click.n);
      gl.uniform1f(loc("uDpr"), pxScale);
      for (const [name, v] of Object.entries(L.uniforms)) {
        const l = loc(name);
        if (!l) continue;
        if (typeof v === "number") gl.uniform1f(l, v);
        else if (typeof v === "boolean") gl.uniform1f(l, v ? 1 : 0);
        else if (typeof v === "string") gl.uniform3fv(l, hexToRgb01(v));
        else if (v.length === 2) gl.uniform2f(l, v[0], v[1]);
        else if (v.length === 3) gl.uniform3f(l, v[0], v[1], v[2]);
        else if (v.length >= 4) gl.uniform4f(l, v[0], v[1], v[2], v[3]);
      }
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      if (first) {
        first = false;
        setReady(true);
      }

      const settling =
        Math.abs(pointer.x - tx) + Math.abs(pointer.y - ty) > 1e-4 || Math.abs(pointer.vx) + Math.abs(pointer.vy) > 1e-3;
      const animating = !reduced && !L.paused && L.speed !== 0;
      if (visible && (animating || settling || click.t < 4)) raf = requestAnimationFrame(frame);
    };

    function requestFrame() {
      if (!raf && visible && !document.hidden) {
        last = performance.now();
        raf = requestAnimationFrame(frame);
      }
    }
    redraw.current = requestFrame;

    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    const io = new IntersectionObserver(([e]) => {
      visible = e.isIntersecting;
      if (visible) requestFrame();
    });
    io.observe(canvas);
    const onVis = () => {
      if (!document.hidden) requestFrame();
    };
    const onLost = (e: Event) => {
      e.preventDefault();
      cancelAnimationFrame(raf);
      raf = 0;
      program = null;
    };
    const onRestored = () => {
      if (init()) requestFrame();
    };

    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("pointerdown", onDown, { passive: true });
    document.documentElement.addEventListener("pointerleave", onLeave);
    document.addEventListener("visibilitychange", onVis);
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);
    resize();

    return () => {
      cancelAnimationFrame(raf);
      redraw.current = () => {};
      ro.disconnect();
      io.disconnect();
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerdown", onDown);
      document.documentElement.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVis);
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      if (program) gl.deleteProgram(program);
      if (vao) gl.deleteVertexArray(vao);
      // Free the context slot once the canvas is really gone (browsers cap live
      // contexts at ~16). StrictMode re-runs effects on the same canvas, so wait a tick.
      const lose = gl.getExtension("WEBGL_lose_context");
      setTimeout(() => {
        if (!canvas.isConnected) lose?.loseContext();
      }, 0);
    };
    // signature captures uniform names/types; values flow through `live`
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fragment, signature, dpr, resolution]);

  return (
    <div className={cn("relative isolate overflow-hidden", className)} style={{ background: fallback, ...style }}>
      <canvas
        ref={canvasRef}
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-0 block size-full transition-opacity duration-700",
          ready ? "opacity-100" : "opacity-0",
        )}
      />
      {children ? <div className="relative z-10 size-full">{children}</div> : null}
    </div>
  );
}
```

### components/opus/lib/use-reduced-motion.ts

```tsx
"use client";

import { useSyncExternalStore } from "react";

const QUERY = "(prefers-reduced-motion: reduce)";

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}

/** true when the OS asks for less motion. SSR-safe (false on the server). */
export function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  );
}

/** Non-hook read for use inside effects. */
export function prefersReducedMotion(): boolean {
  return typeof window !== "undefined" && window.matchMedia(QUERY).matches;
}
```
