Louver
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.
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 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.14. */
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;
/** 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;
/** `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,
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 });
propsRef.current = { slatCount, interactive };
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it || !programLinked(it.program)) return;
const dt = Math.max(elapsed - it.lastElapsed, 0);
it.lastElapsed = elapsed;
// 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,
});
// 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 = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
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;
};
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;
};
const onPointerLeave = () => {
// Leave the position where it was — the blinds close softly in place.
internals.influenceTarget = 0;
};
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
}, []);
// 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 (staticFrame) it.renderNow();
}, [colors, slatCount, angle, openBias, radius, speed, intensity, seed, staticFrame]);
// 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]);
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/louverManual — install dependencies, then copy the source
npm install oglProps
| 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.14 | 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. | |
| className | string | Extra classes for the wrapper. Positioned absolute inset-0 -z-10. |
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.