Sateen
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.
oglfree
"use client";
import * as React from "react";
import { Mesh, Program, Renderer, Triangle } from "ogl";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface 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;
/** 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
];
/** `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,
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 });
propsRef.current = { interactive, reducedMotion };
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it || !programLinked(it.program)) return;
const dt = clamp(elapsed - it.lastElapsed, 0.0001, 0.1);
it.lastElapsed = elapsed;
// 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 : 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,
});
// 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 = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
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);
};
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;
};
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
}, []);
// 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 (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,
paused,
reducedMotion,
]);
// 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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/sateenManual — install dependencies, then copy the source
npm install oglProps
| 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. |
| paused | boolean | Freeze the animation, holding the current frame. | |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.