# Sateen — WebGL Background for React

> Flowing lit fabric: broad domain-drifted folds carry fine silky striations while a satin specular band — an orbiting light caught by the cloth's gradient — sweeps across the drape. Moonlit pewter-blue, billows under the cursor. Original OGL fragment shader, offscreen-paused and reduced-motion-safe. Idles on its own when untouched — a ghost touch billows the cloth.

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

## Install

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

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

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

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `[string, string]` | `moonlit pewter-blue` | Two-stop palette: `[base, sheen]`. `base` is the deep body of the cloth, `sheen` the pale satin highlight that sweeps across it. Any hex string. |
| `speed?` | `number` | `1` | Drift speed multiplier. 1 = default slow drape. |
| `scale?` | `number` | `1` | Overall zoom of the weave, clamped 0.25–4. |
| `rotation?` | `number` | `18` | Drape angle in degrees — tilts the whole cloth. |
| `sheenIntensity?` | `number` | `1` | Strength of the orbiting satin highlight, 0 disables it entirely. |
| `foldDensity?` | `number` | `1` | Frequency multiplier for the broad slow folds, clamped 0.25–4. |
| `striationDensity?` | `number` | `1` | Frequency multiplier for the fine silky ridges riding the folds, clamped 0.25–4. |
| `interactive?` | `boolean` | `true` | When true the cloth billows softly under the cursor and settles after it leaves. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no real pointer over the cloth, a seeded ghost touch drifts across it on a slow Lissajous path, billowing it at a softer strength, so the fabric never looks dead in thumbnails and heroes. The real pointer takes over instantly. Only when `interactive`; never under reduced motion. |
| `paused?` | `boolean` | — | 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 { Sateen } from "@/components/crucible/sateen";
import { DemoScene } from "@/components/registry/demo-scene";

export default function SateenDemo() {
  return (
    <DemoScene
      background={<Sateen />}
      headline="Drape your hero in moonlit satin"
      subhead="Move your cursor — the cloth billows where you touch it."
    />
  );
}
```

## 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 (`sateen.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 SateenProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Two-stop palette: `[base, sheen]`. `base` is the deep body of the cloth,
   * `sheen` the pale satin highlight that sweeps across it. Any hex string.
   * Defaults to moonlit pewter-blue.
   */
  colors?: [string, string];
  /** Drift speed multiplier. 1 = default slow drape. Default 1. */
  speed?: number;
  /** Overall zoom of the weave, clamped 0.25–4. Default 1. */
  scale?: number;
  /** Drape angle in degrees — tilts the whole cloth. Default 18. */
  rotation?: number;
  /**
   * Strength of the orbiting satin highlight, 0 disables it entirely.
   * Default 1.
   */
  sheenIntensity?: number;
  /** Frequency multiplier for the broad slow folds, clamped 0.25–4. Default 1. */
  foldDensity?: number;
  /**
   * Frequency multiplier for the fine silky ridges riding the folds,
   * clamped 0.25–4. Default 1.
   */
  striationDensity?: number;
  /**
   * When true the cloth billows softly under the cursor and settles after it
   * leaves. Default true.
   */
  interactive?: boolean;
  /**
   * Idle autoplay: after ~1.5s with no real pointer over the cloth, a seeded
   * ghost touch drifts across it on a slow Lissajous path, billowing it at a
   * softer strength, so the fabric 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. */
  paused?: boolean;
}

const DEFAULT_COLORS: [string, string] = [
  "#1a2030", // pewter-blue base — the deep body of the cloth
  "#aebedd", // moonlit sheen — the satin highlight
];

/** Ghost takes over this long (ms) after the last real pointer activity. */
const GHOST_DELAY_MS = 1500;
/** Ghost billow strength (the real pointer peaks at 1). */
const GHOST_PRESENCE = 0.6;

/** `uTime` used for the single designed frame under prefers-reduced-motion. */
const REDUCED_MOTION_TIME = 6.0;

/** Deterministic per-instance desync — no Math.random(), no hydration risk. */
let instanceCount = 0;

/** 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.1, 0.125, 0.19];
  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 lit-fabric fragment shader. The cloth is a nested-trig height
 * field: two incommensurate domain-warped sines form broad slow FOLDS, and a
 * fine striation wave rides them with its phase bent by the fold value — the
 * nesting is what makes tight silky ridges follow the drape instead of lying
 * flat. The premium part is the LIGHTING: the field's screen-space gradient
 * (finite differences) is compared against a slowly orbiting light direction
 * and raised to a power, producing a satin specular band that sweeps across
 * the cloth like light tracking over drapery. Deep-base → pale-sheen two-stop
 * ramp, soft exponential tone curve, and ANIMATED film-grain dither (re-hashed
 * every frame) so the grain shimmers filmically. Pointer press advances the
 * fold phase locally through a Gaussian, so the cloth billows where touched —
 * and because the billow lives inside the height field, the sheen catches on
 * it for free. GLSL ES 1.00 (WebGL1/2 compatible). No fract() on spatial
 * coordinates (only inside the hash), so there are no wrap seams.
 */
const FRAGMENT = /* glsl */ `
precision highp float;

varying vec2 vUv;

uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uPointer;    // smoothed, 0–1, y-up
uniform float uInfluence; // eased 0–1 pointer presence

uniform vec3 uBase;       // deep cloth body
uniform vec3 uSheen;      // pale satin highlight
uniform float uSpeed;
uniform float uScale;
uniform float uRotation;  // radians
uniform float uSheenI;
uniform float uFold;      // fold frequency multiplier
uniform float uStriation; // striation frequency (absolute)

float hash21(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

// Fabric height field. q: cloth-space coordinate, pc: pointer in cloth space,
// press: eased billow strength.
float cloth(vec2 q, float t, vec2 pc, float press) {
  // Pointer billow: a soft local phase advance — the cloth lifts where
  // touched, then settles as the influence scalar eases back to zero.
  vec2 d = q - pc;
  float bil = press * exp(-dot(d, d) * 5.0);

  vec2 w = q * uFold;
  // Slow shear drift so the drape never sits still.
  w += 0.42 * vec2(sin(w.y * 0.7 + t * 0.26), sin(w.x * 0.6 - t * 0.21));

  // Broad folds: two incommensurate domain-warped sines.
  float fold =
      sin(w.x * 1.05 + 0.85 * sin(w.y * 1.35 + t * 0.30) + t * 0.20 + bil * 1.9)
    + 0.55 * sin(w.y * 1.65 - 0.70 * sin(w.x * 1.20 - t * 0.24) - t * 0.14 + bil * 1.3);

  // Fine striations riding the folds — the nested phase coupling is the silk:
  // ridge phase bends with the drape, so the weave follows the folds.
  float stri = sin(dot(w, vec2(0.30, 1.0)) * uStriation + fold * 2.5 - t * 0.45 + bil * 3.2);

  return fold * 0.62 + stri * 0.38;
}

void main() {
  vec2 uv = vUv;
  float aspect = uResolution.x / max(uResolution.y, 1.0);
  float t = uTime * uSpeed;

  float cr = cos(uRotation);
  float sr = sin(uRotation);
  mat2 R = mat2(cr, sr, -sr, cr);

  float sc = 2.3 * uScale;
  vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
  vec2 q = R * p * sc;

  // Pointer transformed into the same cloth space so the billow (and its
  // sheen response) stays glued to the cursor under rotation and zoom.
  vec2 pc = R * ((uPointer - 0.5) * vec2(aspect, 1.0)) * sc;
  float press = uInfluence * 1.15;

  // Height + screen-space gradient via finite differences (~1.6px step).
  float e = sc * 1.6 / max(uResolution.y, 1.0);
  float h = cloth(q, t, pc, press);
  float hx = cloth(q + vec2(e, 0.0), t, pc, press);
  float hy = cloth(q + vec2(0.0, e), t, pc, press);
  vec2 grad = vec2(hx - h, hy - h) / e;

  // Satin sheen: gradient alignment with a slowly ORBITING light direction,
  // raised to a power — a tight bright band plus a wide soft skirt. Gated by
  // gradient magnitude so flat spans and ridge crests stay matte.
  float ang = t * 0.12 + 1.9;
  vec2 ld = vec2(cos(ang), sin(ang));
  float gm = length(grad);
  vec2 gn = grad / max(gm, 1e-5);
  float align = clamp(dot(gn, ld), 0.0, 1.0);
  float gfac = smoothstep(0.6, 3.6, gm);
  float sheen = (0.85 * pow(align, 24.0) + 0.30 * pow(align, 6.0)) * gfac * uSheenI;

  // A faint fine counter-drifting underlayer gives the weave depth without a
  // second lighting pass.
  float under = cloth(q * 1.8 + vec2(7.7, 3.9), t * 0.7, pc, press * 0.4);

  // Body shading: valleys sink toward near-black, crests lift toward base.
  float body = 0.33 + 0.25 * h + 0.06 * under;

  // Soft exponential tone curve — highlights roll off asymptotically instead
  // of clipping to flat patches.
  float L = 1.0 - exp(-(max(body, 0.0) + sheen) * 1.25);

  // Two-segment ramp: the body stays in the base hue; the sheen color is
  // reserved for genuine highlights so the cloth never washes out to gray.
  vec3 col = mix(uBase * 0.30, uBase, smoothstep(0.0, 0.55, L));
  col = mix(col, uSheen, smoothstep(0.45, 1.0, L));

  // Gentle vignette to seat the fabric behind content.
  vec2 vg = (uv - 0.5) * vec2(aspect, 1.0);
  col *= 1.0 - 0.30 * dot(vg, vg);

  // ANIMATED film-grain dither: re-hashed every frame so the grain shimmers
  // filmically instead of sitting static, and 8-bit banding never survives.
  float grain = hash21(gl_FragCoord.xy + vec2(fract(t * 61.7) * 173.0, fract(t * 41.3) * 289.0)) - 0.5;
  col += grain * (2.0 / 255.0);

  gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`;

interface Uniform<T> {
  value: T;
}

interface Internals {
  renderer: Renderer;
  gl: Renderer["gl"];
  mesh: Mesh;
  program: Program;
  uTime: Uniform<number>;
  uResolution: Uniform<[number, number]>;
  uPointer: Uniform<[number, number]>;
  uInfluence: Uniform<number>;
  /** Un-smoothed target the pointer eases toward. */
  pointerTarget: [number, number];
  /** Smoothed 0–1 billow strength (mirrors uInfluence). */
  influence: number;
  /** Recent pointer movement, decaying — a parked cursor goes quiet. */
  activity: number;
  inside: boolean;
  /** Deterministic per-instance time offset so multiple instances desync. */
  timeOffset: number;
  lastElapsed: number;
  loseContext: (() => void) | null;
}

/**
 * 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": the GLSL warning is already in the console.
 */
function programLinked(program: Program): boolean {
  return (program as unknown as { uniformLocations?: unknown }).uniformLocations !== undefined;
}

function writeRgb(target: [number, number, number], hex: string) {
  const [r, g, b] = hexToRgb(hex);
  target[0] = r;
  target[1] = g;
  target[2] = b;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);

/**
 * Sateen — flowing lit fabric. Broad domain-drifted folds carry fine silky
 * striations, and a satin specular band — the height field's gradient aligned
 * against a slowly orbiting light — sweeps across the cloth like moonlight
 * over drapery. Deep pewter-blue base with a pale sheen by default; the
 * cursor billows the cloth softly where it touches. Mount-once GL with
 * uniforms mutated in place, DPR ≤2 plus a 1920 max-render-dimension cap,
 * offscreen/hidden-tab pause, and a designed static frame under reduced
 * motion. Drop it inside any `relative` container; it fills and sits behind
 * content.
 */
export function Sateen({
  colors = DEFAULT_COLORS,
  speed = 1,
  scale = 1,
  rotation = 18,
  sheenIntensity = 1,
  foldDensity = 1,
  striationDensity = 1,
  interactive = true,
  idle = true,
  paused = false,
  className,
  ...rest
}: SateenProps) {
  const internalsRef = React.useRef<Internals | null>(null);
  // GL init can fail (context cap, blocked WebGL) or a live context can be
  // evicted — both degrade to a static palette wash, never a blank box.
  const [failed, setFailed] = React.useState(false);
  const reducedMotion = useReducedMotion();

  const [baseColor, sheenColor] = colors;

  // Latest props, readable inside the mount-once effect and event handlers
  // without re-triggering GL setup.
  const propsRef = React.useRef({ interactive, reducedMotion, idle });
  propsRef.current = { interactive, reducedMotion, idle };
  // performance.now() of the last real pointer activity; -Infinity = never.
  const lastRealPointerRef = React.useRef(-Infinity);

  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 = clamp(elapsed - it.lastElapsed, 0.0001, 0.1);
    it.lastElapsed = elapsed;

    // Idle ghost: a slow seeded Lissajous touch while nobody is over the cloth.
    const { interactive: live, idle: ghostOn } = propsRef.current;
    const ghost =
      live &&
      ghostOn &&
      !it.inside &&
      performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS;
    if (ghost) {
      it.pointerTarget[0] = 0.5 + 0.34 * Math.sin(elapsed * 0.29) * Math.cos(elapsed * 0.089);
      it.pointerTarget[1] = 0.5 + 0.28 * Math.sin(elapsed * 0.21 + 1.3);
    }

    // Frame-rate-independent pointer damping: 1 − e^(−dt/τ).
    const kPos = 1 - Math.exp(-dt / 0.16);
    const p = it.uPointer.value;
    p[0] += (it.pointerTarget[0] - p[0]) * kPos;
    p[1] += (it.pointerTarget[1] - p[1]) * kPos;

    // Eased influence scalar: ramps in on movement, decays toward a whisper
    // while the cursor is parked, and fades out fully on leave — the billow
    // never pops.
    it.activity *= Math.exp(-dt / 0.9);
    const target = it.inside ? 0.3 + 0.7 * it.activity : ghost ? GHOST_PRESENCE : 0;
    const tau = target > it.influence ? 0.22 : 0.5;
    it.influence += (target - it.influence) * (1 - Math.exp(-dt / tau));
    it.uInfluence.value = it.influence;

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

  // The container is the RAF gate (IntersectionObserver, via the hook), the
  // ResizeObserver target, and the pointer surface.
  const containerRef = useVisibilityPause<HTMLDivElement>(tick, {
    paused: paused || reducedMotion,
  });

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

  // Mount-once GL setup / full teardown. Prop changes NEVER rebuild the
  // context — the sync effect below mutates uniforms in place.
  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    // The canvas is 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 again — under StrictMode's double-mount a
    // reused JSX canvas would come back permanently black.
    const canvas = document.createElement("canvas");
    canvas.setAttribute("aria-hidden", "true");
    canvas.className = "absolute inset-0 block h-full w-full";
    container.appendChild(canvas);

    let renderer: Renderer;
    let gl: Renderer["gl"];
    try {
      renderer = new Renderer({
        canvas,
        dpr: 1, // real DPR is computed per-resize (cap-aware) below
        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 uTime: 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 uInfluence: Uniform<number> = { value: 0 };

    const program = new Program(gl, {
      vertex: VERTEX,
      fragment: FRAGMENT,
      uniforms: {
        uTime,
        uResolution,
        uPointer,
        uInfluence,
        // Real values are written by the sync effect on mount.
        uBase: { value: [0, 0, 0] as [number, number, number] },
        uSheen: { value: [0, 0, 0] as [number, number, number] },
        uSpeed: { value: 1 },
        uScale: { value: 1 },
        uRotation: { value: 0 },
        uSheenI: { value: 1 },
        uFold: { value: 1 },
        uStriation: { value: 32 },
      },
    });

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

    const internals: Internals = {
      renderer,
      gl,
      mesh,
      program,
      uTime,
      uResolution,
      uPointer,
      uInfluence,
      pointerTarget: [0.5, 0.5],
      influence: 0,
      activity: 0,
      inside: false,
      // Golden-ratio-spaced deterministic offset — several instances on one
      // page desynchronize without any Math.random().
      timeOffset: ((instanceCount++ * 0.618034) % 1) * 48,
      lastElapsed: 0,
      loseContext: loseExt ? () => loseExt.loseContext() : null,
    };
    internalsRef.current = internals;

    const resize = () => {
      const w = Math.max(container.clientWidth, 1);
      const h = Math.max(container.clientHeight, 1);
      // DPR ceiling of 2 plus a 1920 max-render-dimension cap: on ultrawide
      // heroes the buffer stays bounded and upscales imperceptibly.
      const native = clampDpr(dprCap);
      renderer.dpr = Math.max(Math.min(native, 2, 1920 / Math.max(w, h)), 0.5);
      renderer.setSize(w, h);
      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);

    // First frame: under reduced motion this is already the designed still.
    if (propsRef.current.reducedMotion) uTime.value = REDUCED_MOTION_TIME;
    resize();

    const onPointerMove = (e: PointerEvent) => {
      if (!propsRef.current.interactive) return;
      const it = internalsRef.current;
      if (!it) return;
      const r = container.getBoundingClientRect();
      if (r.width === 0 || r.height === 0) return;
      it.pointerTarget[0] = (e.clientX - r.left) / r.width;
      // Flip Y so "up" on screen is 1.
      it.pointerTarget[1] = 1 - (e.clientY - r.top) / r.height;
      it.inside = true;
      it.activity = Math.min(1, it.activity + 0.25);
      lastRealPointerRef.current = performance.now();
    };
    const onPointerLeave = () => {
      const it = internalsRef.current;
      if (!it) return;
      // Recenter so the billow settles back to rest, and fade influence out.
      it.inside = false;
      it.pointerTarget[0] = 0.5;
      it.pointerTarget[1] = 0.5;
      // The ghost resumes after the usual delay rather than snapping in.
      lastRealPointerRef.current = performance.now();
    };
    container.addEventListener("pointermove", onPointerMove);
    container.addEventListener("pointerleave", onPointerLeave);

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

  // Knob changes mutate uniforms in place — never a context rebuild. While the
  // loop is idle (paused / reduced motion) re-render one frame so the static
  // image reflects the new props.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it || !programLinked(it.program)) return;
    const u = it.program.uniforms;
    writeRgb(u.uBase.value as [number, number, number], baseColor);
    writeRgb(u.uSheen.value as [number, number, number], sheenColor);
    u.uSpeed.value = Math.max(speed, 0);
    u.uScale.value = clamp(scale, 0.25, 4);
    u.uRotation.value = (rotation * Math.PI) / 180;
    u.uSheenI.value = Math.max(sheenIntensity, 0);
    u.uFold.value = clamp(foldDensity, 0.25, 4);
    u.uStriation.value = 32 * clamp(striationDensity, 0.25, 4);
    if (!interactive) {
      it.inside = false;
      it.pointerTarget[0] = 0.5;
      it.pointerTarget[1] = 0.5;
    }
    if (paused || reducedMotion) {
      if (reducedMotion) {
        // The designed still: mid-sweep, sheen band composed, no billow.
        it.uTime.value = REDUCED_MOTION_TIME;
        it.uInfluence.value = 0;
        it.uPointer.value[0] = 0.5;
        it.uPointer.value[1] = 0.5;
      }
      it.renderer.render({ scene: it.mesh });
    }
  }, [
    baseColor,
    sheenColor,
    speed,
    scale,
    rotation,
    sheenIntensity,
    foldDensity,
    striationDensity,
    interactive,
    paused,
    reducedMotion,
    // The setup effect rebuilds the program with placeholder uniforms when the
    // DPR cap changes — re-apply the real values after it.
    dprCap,
  ]);

  // Static palette wash if WebGL is unavailable — never a blank box.
  const fallbackBackground = React.useMemo(() => {
    const [br, bg, bb] = hexToRgb(baseColor);
    const [sr, sg, sb] = hexToRgb(sheenColor);
    const rgba = (r: number, g: number, b: number, a: number) =>
      `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
    return `linear-gradient(155deg, ${rgba(sr, sg, sb, 0.3)} 0%, ${rgba(br, bg, bb, 1)} 45%, rgba(4, 6, 11, 1) 100%)`;
  }, [baseColor, sheenColor]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="sateen"
      {...rest}
      className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
    >
      {failed && <div aria-hidden className="absolute inset-0" style={{ background: fallbackBackground }} />}
    </div>
  );
}
```
