# Louver — WebGL Background for React

> Gradient blinds: angled slats tilt open around the cursor to reveal a brighter rosewood-to-indigo field behind, each open slat catching a 1px lit edge, with a slow diagonal shimmer when idle. Original OGL fragment shader, offscreen-paused and reduced-motion-safe. Idles on its own when untouched — a ghost cursor tilts the blinds.

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

## Install

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

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

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

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 (`LouverProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `[string, string]` | `rosewood → indigo duotone` | Two gradient stops `[from, to]` for the field behind the blinds. Any hex strings. |
| `slatCount?` | `number` | `24` | Number of slats. Clamped so no slat renders narrower than ~10 CSS px (the effective count drops automatically on short stages). |
| `angle?` | `number` | `18` | Slat tilt from horizontal, in degrees. |
| `openBias?` | `number` | `0.07` | Baseline openness of every slat, 0–0.8. |
| `radius?` | `number` | `0.32` | Pointer influence radius as a fraction of the stage height (the blinds tilt open within this distance of the cursor). |
| `interactive?` | `boolean` | `true` | Respond to the pointer. When false only the idle shimmer runs. |
| `speed?` | `number` | — | Animation speed multiplier. 1 = default. |
| `intensity?` | `number` | — | Strength of the pointer reveal and lit-edge highlight. 1 = default. |
| `paused?` | `boolean` | — | Freeze the animation, holding the current frame. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no real pointer, a seeded ghost cursor drifts over the blinds on a slow Lissajous path, tilting them open at a slightly softer strength, so the field never looks dead in thumbnails and heroes. The real pointer takes over instantly. Only when `interactive`; never under reduced motion. |
| `className?` | `string` | — | Extra classes for the wrapper. Positioned `absolute inset-0 -z-10`. |

## Usage example

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

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

export default function LouverDemo() {
  return (
    <DemoScene
      background={<Louver />}
      headline="Move your cursor across the blinds"
      subhead="Slats tilt open around the pointer, revealing the gradient behind."
    />
  );
}
```

## 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 (`louver.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 LouverProps {
  /**
   * Two gradient stops `[from, to]` for the field behind the blinds.
   * Any hex strings. Defaults to rosewood → indigo duotone.
   */
  colors?: [string, string];
  /**
   * Number of slats. Clamped so no slat renders narrower than ~10 CSS px
   * (the effective count drops automatically on short stages). Default 24.
   */
  slatCount?: number;
  /** Slat tilt from horizontal, in degrees. Default 18. */
  angle?: number;
  /** Baseline openness of every slat, 0–0.8. Default 0.07. */
  openBias?: number;
  /**
   * Pointer influence radius as a fraction of the stage height (the blinds
   * tilt open within this distance of the cursor). Default 0.32.
   */
  radius?: number;
  /** Respond to the pointer. When false only the idle shimmer runs. Default true. */
  interactive?: boolean;
  /** Animation speed multiplier. 1 = default. */
  speed?: number;
  /** Strength of the pointer reveal and lit-edge highlight. 1 = default. */
  intensity?: number;
  /** Freeze the animation, holding the current frame. */
  paused?: boolean;
  /**
   * Idle autoplay: after ~1.5s with no real pointer, a seeded ghost cursor
   * drifts over the blinds on a slow Lissajous path, tilting them open at a
   * slightly softer strength, so the field never looks dead in thumbnails and
   * heroes. The real pointer takes over instantly. Only when `interactive`;
   * never under reduced motion. Default true.
   */
  idle?: boolean;
  /** Extra classes for the wrapper. Positioned `absolute inset-0 -z-10`. */
  className?: string;
}

const DEFAULT_COLORS: [string, string] = [
  "#9f5b66", // rosewood
  "#312e81", // indigo
];

/** No slat may render narrower than this many CSS px — below it, moiré. */
const MIN_SLAT_CSS_PX = 10;

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

/** `uTime` used for the designed reduced-motion still. */
const STATIC_TIME = 0.35;

/** 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.62, 0.36, 0.4];
  return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255];
}

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

/**
 * Original gradient-blinds fragment shader. The frame is sliced into angled
 * slats; each slat's OPENNESS is the duty cycle of a smooth pulse wave across
 * the slat axis — the fraction of the cell where the slat has tilted away and
 * the brighter gradient field behind shows through. Openness is sampled at
 * the slat's centerline (constant across a slat's width, free to vary along
 * its length), so slats read as rigid vanes that twist open locally around
 * the cursor and close softly after it passes. A slow diagonal shimmer sweeps
 * openness across the field when uninteracted, and every open slat carries a
 * ~1px lit edge at its free lip. Premultiplied-alpha output: closed slat
 * faces stay faintly translucent so the page tone grounds them — no CSS
 * blend-mode crutch. GLSL ES 1.00.
 */
const FRAGMENT = /* glsl */ `
precision highp float;

varying vec2 vUv;

uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse;       // damped pointer, centered aspect-corrected space
uniform float uInfluence;  // eased 0..1 pointer presence
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uCount;      // effective slat count (min-width clamped)
uniform float uAngle;      // radians from horizontal
uniform float uOpenBias;
uniform float uRadius;     // fraction of stage height
uniform float uSpeed;
uniform float uIntensity;
uniform float uSeed;       // 0..1 per-instance
uniform float uStaticFrame; // 1 = designed reduced-motion still

float hash11(float n) {
  return fract(sin(n * 127.1 + 311.7) * 43758.5453);
}

float hash21(vec2 p) {
  p = fract(p * vec2(219.18, 371.43));
  p += dot(p, p + 37.51);
  return fract(p.x * p.y);
}

// Soft plateau pulse over x in [0,1): eases up, holds, eases down.
float pulse(float x) {
  return smoothstep(0.04, 0.42, x) * (1.0 - smoothstep(0.58, 0.96, x));
}

void main() {
  float aspect = uResolution.x / max(uResolution.y, 1.0);
  // Static frames drop the speed multiplier and seed offset so the designed
  // arrangement is identical for every instance and every speed setting.
  float t = mix(uTime * uSpeed + uSeed * 43.0, uTime, uStaticFrame);

  // Centered, aspect-corrected: one unit = stage height, isotropic.
  vec2 p = (vUv - 0.5) * vec2(aspect, 1.0);

  float ca = cos(uAngle);
  float sa = sin(uAngle);
  vec2 nrm = vec2(-sa, ca); // across the slats (slat normal)
  vec2 tng = vec2(ca, sa);  // along the slats

  float s = dot(p, nrm);
  float q = dot(p, tng);

  float x = s * uCount + 64.0; // offset keeps floor() in positive territory
  float i = floor(x);
  float f = fract(x); // 0 = hinge edge, 1 = free edge of this slat

  // Slat centerline point — openness is constant across a slat's width.
  float sc = (i + 0.5 - 64.0) / uCount;
  vec2 cp = nrm * sc + tng * q;

  // ---- openness: pointer tilt + idle diagonal shimmer over a base bias ----
  vec2 md = cp - uMouse;
  float rr = max(uRadius * uRadius, 1e-4);
  float g = exp(-dot(md, md) / rr * 2.4);
  float open01 = g * uInfluence; // local, eased pointer reveal

  float jitter = hash11(i + uSeed * 289.0);
  float diag = cp.x + cp.y;
  float ph = fract(diag * 1.1 - t * 0.10 + jitter * 0.12);
  // The shimmer yields locally where the cursor is working, not globally.
  float shimmer = pulse(ph) * 0.16 * (1.0 - 0.75 * open01);

  float o = clamp(uOpenBias + shimmer + open01 * 0.95 * uIntensity, 0.0, 0.94);

  // ---- the gradient field, and its brighter self behind the blinds ----
  float wob = 0.07 * sin(t * 0.07 + uSeed * 6.2832);
  float gt = clamp(vUv.x * 0.62 + (1.0 - vUv.y) * 0.38 + wob, 0.0, 1.0);
  vec3 grad = mix(uColorA, uColorB, gt);
  vec3 bright = pow(grad, vec3(0.78)) * (0.85 + 0.35 * open01 * uIntensity);

  // ---- closed slat face: a dim tilted vane catching light at its free lip ----
  float faceLight = mix(0.14, 0.30, f);
  faceLight *= 0.9 + 0.2 * jitter;                       // per-slat variation
  faceLight *= 0.65 + 0.35 * smoothstep(0.0, 0.06, f);   // hinge crease shadow
  vec3 face = grad * faceLight;

  // ---- open-gap mask: duty cycle o of the pulse wave across the slat ----
  float aa = uCount / max(uResolution.y, 1.0); // ~1 device px in cell units
  float edgePos = 1.0 - o;
  float m = smoothstep(edgePos - aa, edgePos + aa, f);

  vec3 col = mix(face, bright, m);

  // ---- ~1px lit edge on the free lip of every open slat ----
  float band = (f - edgePos) / (1.6 * aa + 1e-5);
  float edgeAmt = exp(-band * band)
                * smoothstep(0.03, 0.14, o)
                * (0.35 + 0.65 * o);
  vec3 edgeCol = mix(bright, vec3(1.0), 0.55);
  col += edgeCol * edgeAmt * 0.9 * uIntensity;

  // Soft exponential tone curve — highlights roll off instead of clipping.
  col = 1.0 - exp(-col * 1.45);

  // Gentle vignette sinks the frame edges.
  col *= 1.0 - 0.25 * dot(p, p);

  // Time-varying grain kills 8-bit banding in the smooth gradients.
  float grain = hash21(gl_FragCoord.xy + fract(t) * 113.0) - 0.5;
  col += grain * (1.6 / 255.0);

  // Premultiplied alpha: gaps opaque, closed faces faintly translucent.
  float alpha = mix(0.92, 1.0, m);
  gl_FragColor = vec4(max(col, 0.0) * alpha, alpha);
}
`;

/**
 * OGL's Program.setShaders early-returns on a failed link WITHOUT creating
 * `uniformLocations` — rendering such a program throws deep inside OGL.
 * Treat "unlinked" as "skip the frame".
 */
function programLinked(program: Program): boolean {
  return (program as unknown as { uniformLocations?: unknown }).uniformLocations !== undefined;
}

interface Uniform<T> {
  value: T;
}

interface Internals {
  renderer: Renderer;
  mesh: Mesh;
  program: Program;
  uTime: Uniform<number>;
  uResolution: Uniform<[number, number]>;
  uMouse: Uniform<[number, number]>;
  uInfluence: Uniform<number>;
  uCount: Uniform<number>;
  uStaticFrame: Uniform<number>;
  mouseTarget: [number, number];
  influenceTarget: number;
  lastElapsed: number;
  applyCount: () => void;
  renderNow: () => void;
  loseContext: (() => void) | null;
}

/**
 * Louver — gradient blinds. A rosewood → indigo gradient field seen through
 * angled slats: each slat's openness is the duty cycle of a smooth pulse wave
 * across the slat axis, so the vanes tilt open around the cursor to reveal
 * the brighter field behind and close softly after it passes (exponential
 * time-constant damping plus an eased 0→1 influence scalar). A slow diagonal
 * shimmer sweeps slat openness when uninteracted, and every open slat carries
 * a ~1px lit edge at its free lip. Premultiplied transparent output, DPR ≤2,
 * offscreen/hidden-tab pause, reduced-motion designed still, full GL
 * teardown. Drop it inside any `relative` container; it fills and sits behind
 * content.
 */
export function Louver({
  colors = DEFAULT_COLORS,
  slatCount = 24,
  angle = 18,
  openBias = 0.07,
  radius = 0.32,
  interactive = true,
  speed = 1,
  intensity = 1,
  paused = false,
  idle = true,
  className,
}: LouverProps) {
  const internalsRef = React.useRef<Internals | null>(null);
  const [failed, setFailed] = React.useState(false);

  const reducedMotion = useReducedMotion();
  const staticFrame = paused || reducedMotion;

  // Per-instance deterministic seed — SSR/hydration-safe, desynchronizes
  // multiple instances on one page without Math.random().
  const reactId = React.useId();
  const seed = React.useMemo(() => {
    let h = 0;
    for (let i = 0; i < reactId.length; i++) {
      h = (h * 31 + reactId.charCodeAt(i)) >>> 0;
    }
    return (h % 1024) / 1024;
  }, [reactId]);

  // Latest props, read from resize/pointer handlers without re-running setup.
  const propsRef = React.useRef({ slatCount, interactive, idle });
  propsRef.current = { slatCount, interactive, idle };
  // performance.now() of the last real pointer activity; -Infinity = never.
  const lastRealPointerRef = React.useRef(-Infinity);
  const insideRef = React.useRef(false);

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it || !programLinked(it.program)) return;
    if (elapsed < it.lastElapsed) it.lastElapsed = elapsed;
    const dt = Math.max(elapsed - it.lastElapsed, 0);
    it.lastElapsed = elapsed;

    // Idle ghost: a slow seeded Lissajous cursor in the shader's centered,
    // aspect-corrected frame, at reduced presence.
    const { interactive: live, idle: ghostOn } = propsRef.current;
    if (live && ghostOn && !insideRef.current && performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS) {
      const res = it.uResolution.value;
      const aspect = res[0] / Math.max(res[1], 1);
      it.mouseTarget[0] = 0.36 * aspect * Math.sin(elapsed * 0.31) * Math.cos(elapsed * 0.097);
      it.mouseTarget[1] = 0.3 * Math.sin(elapsed * 0.23 + 1.3);
      it.influenceTarget = GHOST_PRESENCE;
    }

    // Frame-rate-independent exponential damping (1 - e^(-dt/tau)).
    const km = 1 - Math.exp(-dt / 0.12);
    const m = it.uMouse.value;
    m[0] += (it.mouseTarget[0] - m[0]) * km;
    m[1] += (it.mouseTarget[1] - m[1]) * km;

    // Influence rises quickly, falls slowly — blinds close softly after the
    // cursor passes instead of snapping shut.
    const tau = it.influenceTarget > it.uInfluence.value ? 0.18 : 0.55;
    const ki = 1 - Math.exp(-dt / tau);
    it.uInfluence.value += (it.influenceTarget - it.uInfluence.value) * ki;

    it.uTime.value = elapsed;
    it.renderer.render({ scene: it.mesh });
  }, []);

  const containerRef = useVisibilityPause<HTMLDivElement>(tick, {
    paused: staticFrame,
  });

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

  // GL setup / teardown. Mounts exactly once — every knob change afterwards
  // mutates uniforms in place (second effect below), never rebuilds context.
  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    // Created per mount, never reused: once WEBGL_lose_context has killed a
    // canvas's context (our teardown), that canvas can only ever return the
    // same dead context — 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 effectiveDpr = clampDpr(dprCap, 2);

    let renderer: Renderer;
    let gl: Renderer["gl"];
    try {
      renderer = new Renderer({
        canvas,
        dpr: effectiveDpr,
        alpha: true,
        premultipliedAlpha: true,
        antialias: 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 uTime: Uniform<number> = { value: 0 };
    const uResolution: Uniform<[number, number]> = {
      value: [gl.drawingBufferWidth || 1, gl.drawingBufferHeight || 1],
    };
    const uMouse: Uniform<[number, number]> = { value: [0, 0] };
    const uInfluence: Uniform<number> = { value: 0 };
    const uCount: Uniform<number> = { value: 24 };
    const uStaticFrame: Uniform<number> = { value: 0 };

    const program = new Program(gl, {
      vertex: VERTEX,
      fragment: FRAGMENT,
      uniforms: {
        uTime,
        uResolution,
        uMouse,
        uInfluence,
        uCount,
        uStaticFrame,
        uColorA: { value: [0, 0, 0] },
        uColorB: { value: [0, 0, 0] },
        uAngle: { value: 0 },
        uOpenBias: { value: 0 },
        uRadius: { value: 0.32 },
        uSpeed: { value: 1 },
        uIntensity: { value: 1 },
        uSeed: { value: 0 },
      },
    });

    const geometry = new Triangle(gl);
    const mesh = new Mesh(gl, { geometry, program });
    const loseExt = gl.getExtension("WEBGL_lose_context");

    const renderNow = () => {
      if (programLinked(program)) renderer.render({ scene: mesh });
    };

    // Minimum-slat-pixel-width clamp: on short stages the effective slat
    // count drops so no slat renders narrower than MIN_SLAT_CSS_PX. (Cell
    // units are isotropic with 1 unit = stage height, so slat width in CSS px
    // is height / count regardless of angle.)
    const applyCount = () => {
      const h = Math.max(container.clientHeight, 1);
      const maxCount = Math.max(2, Math.floor(h / MIN_SLAT_CSS_PX));
      const want = Math.max(2, Math.round(propsRef.current.slatCount));
      uCount.value = Math.min(want, maxCount);
    };

    const internals: Internals = {
      renderer,
      mesh,
      program,
      uTime,
      uResolution,
      uMouse,
      uInfluence,
      uCount,
      uStaticFrame,
      mouseTarget: [0, 0],
      influenceTarget: 0,
      lastElapsed: 0,
      applyCount,
      renderNow,
      loseContext: loseExt ? () => loseExt.loseContext() : null,
    };
    internalsRef.current = internals;

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = container;
      renderer.setSize(Math.max(w, 1), Math.max(h, 1));
      uResolution.value[0] = gl.drawingBufferWidth;
      uResolution.value[1] = gl.drawingBufferHeight;
      applyCount();
      // Keep the frame correct even while the loop is idle (paused/reduced).
      renderNow();
    };

    const ro = new ResizeObserver(resize);
    ro.observe(container);
    resize();

    // Pointer → centered, aspect-corrected space (1 unit = stage height),
    // matching the shader's coordinate frame.
    const toLocal = (e: PointerEvent): [number, number] | null => {
      const r = container.getBoundingClientRect();
      if (r.width === 0 || r.height === 0) return null;
      const aspect = r.width / r.height;
      return [
        ((e.clientX - r.left) / r.width - 0.5) * aspect,
        0.5 - (e.clientY - r.top) / r.height,
      ];
    };

    const onPointerEnter = (e: PointerEvent) => {
      if (!propsRef.current.interactive) return;
      const pos = toLocal(e);
      if (!pos) return;
      // Snap position on entry (influence eases in from 0, so no fly-in
      // streak) — the reveal blooms in place under the cursor.
      internals.mouseTarget[0] = pos[0];
      internals.mouseTarget[1] = pos[1];
      internals.uMouse.value[0] = pos[0];
      internals.uMouse.value[1] = pos[1];
      internals.influenceTarget = 1;
      insideRef.current = true;
      lastRealPointerRef.current = performance.now();
    };
    const onPointerMove = (e: PointerEvent) => {
      if (!propsRef.current.interactive) return;
      const pos = toLocal(e);
      if (!pos) return;
      internals.mouseTarget[0] = pos[0];
      internals.mouseTarget[1] = pos[1];
      internals.influenceTarget = 1;
      insideRef.current = true;
      lastRealPointerRef.current = performance.now();
    };
    const onPointerLeave = () => {
      // Leave the position where it was — the blinds close softly in place.
      internals.influenceTarget = 0;
      insideRef.current = false;
      // The ghost resumes after the usual delay rather than snapping in.
      lastRealPointerRef.current = performance.now();
    };
    container.addEventListener("pointerenter", onPointerEnter);
    container.addEventListener("pointermove", onPointerMove);
    container.addEventListener("pointerleave", onPointerLeave);

    return () => {
      internalsRef.current = null;
      ro.disconnect();
      canvas.removeEventListener("webglcontextlost", onContextLost);
      container.removeEventListener("pointerenter", onPointerEnter);
      container.removeEventListener("pointermove", onPointerMove);
      container.removeEventListener("pointerleave", onPointerLeave);
      internals.loseContext?.();
      canvas.remove();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dprCap]);

  // Live-sync knobs into the running program — uniforms mutate in place,
  // the GL context is never rebuilt. Re-renders if the loop is idle so
  // static frames reflect prop changes.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it || !programLinked(it.program)) return;
    const u = it.program.uniforms;
    u.uColorA.value = hexToRgb(colors[0]);
    u.uColorB.value = hexToRgb(colors[1]);
    u.uAngle.value = (angle * Math.PI) / 180;
    u.uOpenBias.value = Math.min(Math.max(openBias, 0), 0.8);
    u.uRadius.value = Math.max(radius, 0.02);
    u.uSpeed.value = speed;
    u.uIntensity.value = intensity;
    u.uSeed.value = seed;
    it.applyCount();
    if (!interactive || !idle) it.influenceTarget = 0;
    if (staticFrame) it.renderNow();
    // dprCap: the setup effect rebuilds the program with placeholder uniforms
    // when the cap changes — re-apply the real values after it.
  }, [colors, slatCount, angle, openBias, radius, speed, intensity, seed, staticFrame, interactive, idle, dprCap]);

  // Reduced motion: a designed still — the diagonal shimmer frozen as a
  // graceful open/closed arrangement, pointer quiet. Paused merely holds the
  // current frame.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    if (reducedMotion) {
      it.uStaticFrame.value = 1;
      it.uTime.value = STATIC_TIME;
      it.uInfluence.value = 0;
      it.influenceTarget = 0;
    } else {
      it.uStaticFrame.value = 0;
    }
    if (staticFrame) it.renderNow();
  }, [reducedMotion, staticFrame, dprCap]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="louver"
      className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
    >
      {failed && (
        <div
          aria-hidden
          className="absolute inset-0"
          style={{
            background: `linear-gradient(125deg, ${colors[0]}33, ${colors[1]}40)`,
          }}
        />
      )}
    </div>
  );
}
```
