A calm, photographic hero background for a light page. One fragment shader draws a lime plaster wall meeting a pale oak floor, seen by a camera turned slightly so the wall recedes. For every visible point it follows a single ray back toward the sun and asks what stands in the way: a tall two-light garden door, a sheer unbleached linen curtain drawn across one light, and a tree outside. Each obstacle is blurred by its own distance from the wall, so the window bars stay fairly crisp, the curtain folds are a little softer and the far leaves melt into round dapples, which is how a real sun disc behaves. Light that passes the linen is warmed, fold edges let less through, and the patch bends over the skirting onto the floor. The breeze rises and falls on its own; moving a pointer leans the camera and the curtain edge, and a click or the Breeze button sends a gust that swells, overshoots and settles. On phones the lens widens so the window patch stays in frame and vertical swipes still scroll.
import { useEffect, useRef, useState } from "react";
// The hero copy that sits on the wall. Everything behind it is one traced shader.
const hero = {
eyebrow: "Halden Row Studio",
title: "Rooms that keep the afternoon",
line: "Lime plaster, unbleached linen and furniture made to be lived with. Visits by appointment, Tuesday to Saturday.",
cta: "Book a visit",
};
const SPEED = 1; // 0.5 is a stiller day, 2 a breezier one
const FONT = '"Inter Tight", "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
const SERIF = '"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif';
const VERT = `ATTR vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
// A plaster wall (z = 0) meeting an oak floor (y = 0). The sun sits behind the camera on the right.
// Every lit point traces one ray back toward the sun through three layers: the window frame,
// the sheer curtain hanging in it and a tree outside. Each layer is softened by its own distance,
// the way a real sun disc softens a shadow, so near folds stay crisp and far leaves bloom into dapples.
const SCENE = `
precision highp float;
uniform vec2 uRes; uniform float uT; uniform float uGust; uniform vec2 uLean; uniform float uGrain;
const vec3 SUN = vec3(0.70, 0.46, 0.55); // direction toward the sun (normalised in main)
const vec3 SUNCOL = vec3(1.0, 0.80, 0.58) * 3.3;
const vec3 SKY = vec3(0.50, 0.60, 0.78) * 0.16;
const vec3 PLASTER = vec3(0.84, 0.78, 0.70);
const float DISC = 0.0085; // apparent sun radius, a little generous for a soft photograph
const float D_FRAME = 1.6, D_CURTAIN = 1.25, D_TREE = 4.0;
float h21(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vn(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), u.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), u.x), u.y); }
float fbm(vec2 p){ float a = 0.5, s = 0.0; for (int i = 0; i < 4; i++){ s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s; }
float box1(float x, float a, float b, float w){ return smoothstep(a - w, a + w, x) - smoothstep(b - w, b + w, x); }
// one layer of leaves: a jittered ellipse per cell, turned to its own angle, fluttering on its stem
// how thick the canopy is in front of wall point s: heavier toward the upper right
float canopy(vec2 s){ return smoothstep(-0.4, 1.1, s.x * 0.45 + s.y * 0.75 - 1.05 + uLean.y * 0.1 + 0.35 * (fbm(s * 1.3) - 0.5)); }
float leafLayer(vec2 p, float w, float sc, vec2 off, float gain, float t, float seed){
vec2 i0 = floor(p); float c = 0.0;
for (int j = -1; j <= 1; j++) for (int k = -1; k <= 1; k++){
vec2 id = i0 + vec2(float(j), float(k)) + seed;
float h = h21(id), h2 = h21(id + 31.7), h3 = h21(id + 57.3);
vec2 ctr = id - seed + 0.25 + 0.5 * vec2(h, h2);
float dens = canopy((ctr - off) / sc) * gain - (gain > 1.28 ? 0.1 : 0.0); // decided per leaf, never per pixel
if (h3 > dens) continue;
ctr += 0.05 * vec2(sin(t * (1.3 + h) + h2 * 6.0), cos(t * (1.1 + h2) + h * 6.0)) * (1.0 + 2.0 * uGust);
float a = h * 6.283 + 0.25 * sin(t * (0.9 + h3) + h * 9.0) * (0.6 + uGust);
vec2 d = p - ctr; d = mat2(cos(a), -sin(a), sin(a), cos(a)) * d;
vec2 rad = vec2(0.46, 0.21) * (0.8 + 0.3 * h2);
d.x += 0.12 * d.y * d.y / rad.y; // a little curve, like a real leaf
float e = (length(d / rad) - 1.0) * rad.y;
c = max(c, 1.0 - smoothstep(-w, w, e));
}
return c;
}
// the curtain's folds: depth toward the window, which grows with the drop from the rod and with the breeze
float folds(float x, float y, float t){
float drop = clamp((2.75 - y) / 2.4, 0.0, 1.0);
float sway = (0.35 + 0.65 * drop) * (0.6 + uGust);
float ph = t * 0.55 + sin(t * 0.23) * 1.3;
return 0.050 * sin(x * 9.0 + 0.8 * sin(y * 1.3 + t * 0.4)) + 0.024 * sin(x * 21.0 - y * 0.7 + 1.7)
+ sway * 0.06 * sin(x * 3.1 - ph + y * 0.9) + sway * 0.025 * sin(x * 6.3 + ph * 1.6);
}
// how much sun passes the window, curtain and tree at wall-aligned point s, from a surface 'dz' off the wall
vec3 sunThrough(vec2 s, float pz, float t, vec3 L){
float k = 1.0 / L.z;
// window: a tall two-light garden door, sheared because its wall sits at an angle to ours
float bFrame = DISC * (D_FRAME - pz) * k;
vec2 w = vec2(s.x - 0.26 * (s.y - 1.4), s.y);
float win = box1(w.x, -0.55, 1.75, bFrame) * box1(w.y, -1.1, 2.5, bFrame);
win *= 1.0 - box1(w.x, 0.56, 0.64, bFrame) ; // the upright between the lights
win *= 1.0 - box1(w.y, 1.70, 1.77, bFrame) * 0.97; // the transom
if (win < 0.002) return vec3(0.0);
// curtain: drawn across the left light, its leading edge billows; folds shift the projection
float bC = DISC * (D_CURTAIN - pz) * k;
float f = folds(s.x, s.y, t);
vec2 sc = s - L.xy * k * f;
float slope = (folds(s.x + 0.004, s.y, t) - f) / 0.004;
float edge = 0.72 + 0.10 * sin(t * 0.5 + s.y * 1.6) * (0.5 + uGust) + 0.05 * (2.6 - s.y) + uLean.x * 0.08;
float cover = 1.0 - smoothstep(edge - bC - 0.006, edge + bC + 0.006, sc.x);
float hem = smoothstep(-0.75 - bC, -0.75 + bC, s.y - L.y / L.z * f * 0.3); // the hem stops just above the sill
cover *= hem;
float path = 1.0 + slope * slope * 2.2;
float slub = 0.88 + 0.12 * vn(vec2(sc.x * 260.0, sc.y * 4.0)) + 0.05 * vn(vec2(sc.x * 70.0, sc.y * 1.2));
vec3 linen = exp(-vec3(0.82, 0.90, 1.02) * 0.95 * path * slub); // unbleached: warms the light it lets through
float doubled = box1(sc.x, edge - 0.05, edge, bC + 0.004) * cover; // the turned-back edge is two layers thick
vec3 T = mix(vec3(1.0), linen * (1.0 - 0.35 * doubled), cover);
// tree: two layers of foliage in the breeze, softened by distance into round dapples
float bT = DISC * (D_TREE - pz) * k;
vec2 g = vec2(sin(t * 0.37) + 0.5 * sin(t * 0.91 + 1.0), cos(t * 0.29) * 0.6) * (0.035 + 0.05 * uGust);
vec2 q = s + g;
float leaf = leafLayer(q * 6.0, bT * 3.0 + 0.01, 6.0, vec2(0.0), 1.25, t, 0.0);
leaf = max(leaf, leafLayer(q * 9.5 + 4.0, bT * 4.5 + 0.01, 9.5, vec2(4.0), 1.3, t * 1.2, 11.0) * 0.9);
// a branch crossing the upper light
float br = abs(s.y - 2.05 - 0.38 * (s.x - 0.2) - 0.06 * sin(s.x * 3.0 + g.x * 4.0));
leaf = max(leaf, (1.0 - smoothstep(0.012, 0.012 + bT * 2.2, br)) * smoothstep(-0.6, 0.3, s.x));
// the far canopy, too distant to show single leaves, only dims the light in broad soft patches
float far = smoothstep(0.35, 0.75, fbm(s * 0.9 + g * 0.6 + vec2(1.7, 0.0)) + canopy(s) * 0.25);
return T * win * (1.0 - leaf * 0.93) * (1.0 - 0.42 * far);
}
vec3 tone(vec3 x){ x = (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14); return pow(clamp(x, 0.0, 1.0), vec3(1.0 / 2.2)); }
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float t = uT;
vec3 L = normalize(SUN);
// a camera standing back from the wall, turned a few degrees so the wall recedes to the left
vec3 ro = vec3(0.55 + uLean.x * 0.04, 1.30 + uLean.y * 0.02, 3.7);
vec3 ta = vec3(0.05, 1.12, 0.0);
vec3 fw = normalize(ta - ro), rt = normalize(cross(fw, vec3(0, 1, 0))), up = cross(rt, fw);
float aspect = uRes.x / uRes.y;
float lensF = 1.55 * clamp(aspect / 1.6, 0.62, 1.0);
vec3 rd = normalize(fw * lensF + rt * uv.x + up * uv.y);
float tw = -ro.z / rd.z;
float tf = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
bool floorHit = tf < tw;
vec3 P = ro + rd * min(tw, tf);
vec3 N = floorHit ? vec3(0, 1, 0) : vec3(0, 0, 1);
vec3 alb;
if (floorHit) {
// pale limed oak in long boards
float board = floor(P.x * 5.2);
float gr = fbm(vec2(P.x * 5.2 * 3.0, P.z * 1.4 + board * 7.0)) ;
alb = vec3(0.74, 0.62, 0.49) * (0.86 + 0.18 * gr + 0.06 * h21(vec2(board, 3.0)));
alb *= 1.0 - 0.45 * smoothstep(0.47, 0.5, abs(fract(P.x * 5.2) - 0.5));
} else {
// lime plaster: trowel clouds in the colour, and a fine relief the low sun rakes across
vec2 p = P.xy;
float c = fbm(p * 1.6), c2 = fbm(p * 7.0 + 3.0);
alb = PLASTER * (0.93 + 0.07 * c + 0.03 * c2) * mix(vec3(1.0), vec3(1.02, 0.99, 0.95), c);
float e = 0.004, s0 = fbm(p * 26.0) * 0.45 + fbm(p * 5.0) * 0.55;
float sx = fbm((p + vec2(e, 0)) * 26.0) * 0.45 + fbm((p + vec2(e, 0)) * 5.0) * 0.55;
float sy = fbm((p + vec2(0, e)) * 26.0) * 0.45 + fbm((p + vec2(0, e)) * 5.0) * 0.55;
N = normalize(vec3(-(sx - s0) / e * 0.0055, -(sy - s0) / e * 0.0055, 1.0));
// a skirting board at the foot of the wall
float sk = 1.0 - smoothstep(0.115, 0.12, P.y);
alb = mix(alb, vec3(0.80, 0.76, 0.70), sk);
if (P.y < 0.12) N = normalize(vec3(0, smoothstep(0.10, 0.12, P.y) * 0.8, 1.0));
}
vec2 s = P.xy - L.xy / L.z * P.z; // where this point's sun ray crosses the wall plane
vec3 sun = sunThrough(s, P.z, t, L);
float ndl = max(dot(N, L), 0.0);
// the corner where wall meets floor gathers shade
float corner = floorHit ? P.z : P.y;
float ao = 1.0 - 0.55 * exp(-corner / 0.10) - 0.2 * exp(-corner / 0.5);
// light bounced off the lit patch and the floor: a broad, warm glow around the sun
vec2 sb = s + vec2(0.0, 0.0);
float lit = box1(sb.x - 0.26 * (sb.y - 1.4), -0.45, 1.65, 0.7) * box1(sb.y, -0.9, 2.4, 0.6);
vec3 bounce = vec3(1.0, 0.78, 0.55) * (0.15 * lit + 0.04);
vec3 amb = (SKY * (0.75 + 0.25 * N.y) + vec3(0.33, 0.27, 0.21) * 0.30 + bounce) * ao;
vec3 col = alb * (SUNCOL * sun * ndl + amb);
// the faint haze of light that plaster scatters just under its surface
col += alb * vec3(1.0, 0.7, 0.45) * 0.035 * length(sun);
// lens: soft fall-off to the corners, filmic curve, fine grain
vec2 q = gl_FragCoord.xy / uRes;
col *= 0.80 + 0.20 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.25);
vec3 o = tone(col * 0.9);
o += (h21(gl_FragCoord.xy + uGrain * 13.7) - 0.5) * 0.022;
outC = vec4(o, 1.0);
}
`;
// What browsers without WebGL see: the same wall, set with CSS gradients. Reduced motion gets one still frame of the shader.
function Still() {
const spot = (x: number, y: number, w: number, h: number, a: number) => `radial-gradient(${w}px ${h}px at ${x}% ${y}%, rgba(120,98,74,${a}) 55%, transparent 72%)`;
const leaves = [[62, 12, 60, 20, 0.5], [70, 24, 72, 24, 0.45], [79, 8, 56, 20, 0.5], [85, 31, 70, 22, 0.42], [74, 42, 52, 18, 0.4], [91, 17, 64, 20, 0.5], [66, 34, 48, 16, 0.35], [88, 46, 58, 18, 0.4], [80, 56, 44, 16, 0.35], [72, 4, 50, 18, 0.45], [83, 19, 40, 14, 0.4]] as const;
return (
<div aria-hidden="true" className="absolute inset-0 overflow-hidden" style={{ background: "linear-gradient(180deg, #8f8172 0%, #9c8d7c 70%, #8a7663 88%, #6f5d4c 100%)" }}>
<div className="absolute" style={{ left: "37%", top: "-4%", width: "52%", height: "98%", transform: "skewX(-14deg)", filter: "blur(2.5px)", boxShadow: "0 0 80px 20px rgba(240,220,190,0.18)" }}>
<div className="absolute inset-y-0 left-0 w-[48%]" style={{ background: "repeating-linear-gradient(90deg, rgba(236,218,190,0.95) 0 18px, rgba(220,199,168,0.95) 18px 30px, rgba(240,224,198,0.95) 30px 52px)" }} />
<div className="absolute inset-y-0 right-0 w-[49%]" style={{ background: "linear-gradient(180deg, #f1e3cc, #f5e9d6)" }} />
<div className="absolute inset-y-0 left-[48%] w-[3%]" style={{ background: "#a39280" }} />
<div className="absolute inset-x-0 top-[24%] h-[1.6%]" style={{ background: "#a39280" }} />
</div>
<div className="linen-still-leaves absolute inset-0" style={{ filter: "blur(7px)", background: leaves.map((l) => spot(...l)).join(", ") }} />
<style>{`
@keyframes sheer-linen-curtain-leaves { from { transform: translate(0, 0) rotate(0deg); } to { transform: translate(6px, 3px) rotate(0.4deg); } }
.linen-still-leaves { animation: sheer-linen-curtain-leaves 3.2s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) { .linen-still-leaves { animation: none; } }
`}</style>
</div>
);
}
export default function SheerLinenCurtainBackground() {
const stage = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const [shown, setShown] = useState<"wait" | "gl" | "still">("wait");
const [paused, setPaused] = useState(false);
const [live, setLive] = useState(true);
const api = useRef<{ gust: () => void; pause: (p: boolean) => void } | null>(null);
useEffect(() => {
const box = stage.current, cv = canvas.current;
if (!box || !cv) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const opts = { antialias: false, alpha: false, powerPreference: "high-performance" as const };
const gl2 = cv.getContext("webgl2", opts) as WebGL2RenderingContext | null;
const gl = (gl2 ?? cv.getContext("webgl", opts)) as WebGLRenderingContext | null;
if (!gl) { setShown("still"); setLive(false); return; }
if (reduce) setLive(false); // one exposure of the shader, then no loop at all
const head = gl2 ? "#version 300 es\n#define ATTR in\n" : "#define ATTR attribute\n";
const fhead = gl2 ? "#version 300 es\nprecision highp float;\nout vec4 outC;\n" : "#define outC gl_FragColor\n";
const compile = (type: number, src: string) => {
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, src); gl.compileShader(sh);
return gl.getShaderParameter(sh, gl.COMPILE_STATUS) ? sh : null;
};
const v = compile(gl.VERTEX_SHADER, head + VERT), f = compile(gl.FRAGMENT_SHADER, fhead + SCENE);
const prog = gl.createProgram();
if (!v || !f || !prog) { setShown("still"); setLive(false); return; }
gl.attachShader(prog, v); gl.attachShader(prog, f); gl.bindAttribLocation(prog, 0, "aPos"); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { setShown("still"); setLive(false); return; }
const u = Object.fromEntries(["uRes", "uT", "uGust", "uLean", "uGrain"].map((n) => [n, gl.getUniformLocation(prog, n)]));
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(prog);
let W = 0, H = 0, scale = 1;
const s = { t: 12, paused: false, visible: true, raf: 0, last: 0, frame: 0, slow: 0, sum: 0, first: true,
gust: 0, gv: 0, kick: 0, tx: 0, ty: 0, lx: 0, ly: 0, fps: 0 };
const size = () => {
const r = box.getBoundingClientRect();
const dpr = Math.min(1.5, window.devicePixelRatio || 1) * scale;
const k = Math.min(1, Math.sqrt(9e5 / (r.width * r.height * dpr * dpr))); // never more than ~0.9 million pixels
const w = Math.max(2, Math.round(r.width * dpr * k)), h = Math.max(2, Math.round(r.height * dpr * k));
if (w === W && h === H) return;
W = w; H = h; cv.width = W; cv.height = H;
};
const draw = () => {
gl.viewport(0, 0, W, H);
gl.uniform2f(u.uRes, W, H); gl.uniform1f(u.uT, s.t); gl.uniform1f(u.uGust, s.gust);
gl.uniform2f(u.uLean, s.lx, s.ly); gl.uniform1f(u.uGrain, s.frame % 61);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
s.raf = 0;
if (!s.visible) return;
const raw = s.last ? (now - s.last) / 1000 : 0.016;
const dt = Math.min(0.05, raw);
s.last = now; s.frame++;
if (!s.paused && !reduce) {
// the breeze is a damped spring: a gust swells over a second, overshoots a little and dies away
s.t += dt * SPEED * (1 + s.gust * 1.4);
const target = 0.22 + 0.18 * Math.sin(s.t * 0.11) * Math.sin(s.t * 0.07 + 1) + s.kick;
s.gv += ((target - s.gust) * 3.2 - s.gv * 2.2) * dt; s.gust += s.gv * dt;
s.kick *= Math.exp(-dt * 0.9);
s.lx += (s.tx - s.lx) * (1 - Math.exp(-dt * 1.5)); s.ly += (s.ty - s.ly) * (1 - Math.exp(-dt * 1.5));
}
draw();
if (s.first) { s.first = false; setShown("gl"); }
if (!s.paused && !reduce) {
if (s.frame > 10 && s.frame <= 40) s.sum += raw;
if (s.frame === 40) { s.fps = 30 / s.sum; (box as HTMLElement).dataset.fps = s.fps.toFixed(1); if (s.sum / 30 > 0.02) { scale = Math.max(0.4, scale * Math.sqrt(0.0167 / (s.sum / 30))); size(); } }
if (raw > 0.024) s.slow++; else s.slow = Math.max(0, s.slow - 2);
if (s.slow > 30 && scale > 0.4) { scale *= 0.85; s.slow = 0; size(); }
s.raf = requestAnimationFrame(frame);
}
};
const wake = () => { if (!s.raf && s.visible) { s.last = 0; s.raf = requestAnimationFrame(frame); } };
api.current = {
gust: () => { s.kick = 0.9; wake(); },
pause: (p) => { s.paused = p; wake(); },
};
size();
const ro = new ResizeObserver(() => { size(); if (s.paused) { draw(); } wake(); });
ro.observe(box);
const io = new IntersectionObserver(([e]) => { s.visible = e.isIntersecting && !document.hidden; if (s.visible) wake(); }, { threshold: 0.02 });
io.observe(box);
const vis = () => { s.visible = !document.hidden; if (s.visible) wake(); };
document.addEventListener("visibilitychange", vis);
const move = (e: PointerEvent) => { const r = box.getBoundingClientRect(); s.tx = ((e.clientX - r.left) / r.width) * 2 - 1; s.ty = 1 - ((e.clientY - r.top) / r.height) * 2; };
const leave = () => { s.tx = 0; s.ty = 0; };
const down = (e: PointerEvent) => { if ((e.target as HTMLElement).closest("a,button")) return; s.kick = 0.7; };
box.addEventListener("pointermove", move); box.addEventListener("pointerleave", leave); box.addEventListener("pointerdown", down);
const lost = (e: Event) => { e.preventDefault(); setShown("still"); setLive(false); };
cv.addEventListener("webglcontextlost", lost);
wake();
return () => {
cancelAnimationFrame(s.raf); ro.disconnect(); io.disconnect();
document.removeEventListener("visibilitychange", vis);
box.removeEventListener("pointermove", move); box.removeEventListener("pointerleave", leave); box.removeEventListener("pointerdown", down);
cv.removeEventListener("webglcontextlost", lost);
api.current = null;
};
}, []);
const togglePause = () => { const p = !paused; setPaused(p); api.current?.pause(p); };
const pill = "inline-flex h-10 min-w-[40px] items-center justify-center gap-2 rounded-full border border-stone-900/15 bg-[#efe6da]/70 px-3 text-[10.5px] font-semibold uppercase tracking-[0.2em] text-stone-900 backdrop-blur-sm transition-colors duration-200 hover:bg-[#f6efe5] outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#c9bcab] disabled:opacity-40 sm:px-4";
return (
<section className="relative isolate overflow-hidden bg-[#bfb3a3] text-stone-900" style={{ fontFamily: FONT }}>
<div ref={stage} className="relative h-[680px] w-full lg:h-[760px]" style={{ background: "linear-gradient(180deg, #b9ad9c 0%, #c2b6a5 75%, #8f7a64 100%)", touchAction: "pan-y" }}>
<canvas ref={canvas} aria-hidden="true" className={`absolute inset-0 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${shown === "gl" ? "opacity-100" : "opacity-0"}`} />
{shown === "still" && <Still />}
<div className="pointer-events-none relative z-10 mx-auto flex h-full max-w-7xl flex-col justify-center px-6 sm:px-10 lg:px-14">
<div className="max-w-[34rem]">
<p className="flex items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.32em] text-stone-900/75">
<span aria-hidden="true" className="h-px w-8 bg-stone-900/50" />
{hero.eyebrow}
</p>
<h1 className="mt-5 text-[46px] font-normal leading-[0.98] tracking-[-0.025em] text-stone-950 sm:text-[64px] lg:text-[80px]" style={{ fontFamily: SERIF }}>
{hero.title}
</h1>
<p className="mt-6 max-w-[26rem] text-[16px] leading-[1.55] text-stone-900/85 sm:text-[17px]">{hero.line}</p>
<a href="#" className="pointer-events-auto mt-9 inline-flex h-12 items-center gap-3 rounded-full bg-stone-950 pl-6 pr-5 text-[14px] font-medium tracking-[-0.005em] text-[#f4ece1] shadow-[0_14px_30px_-14px_rgba(40,28,15,0.7)] outline-none transition-colors duration-200 hover:bg-stone-800 focus-visible:ring-2 focus-visible:ring-stone-950 focus-visible:ring-offset-2 focus-visible:ring-offset-[#c9bcab]">
{hero.cta}
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10h11M11 5.5 15.5 10 11 14.5" /></svg>
</a>
</div>
</div>
{live && (
<div className="absolute bottom-6 right-6 z-10 flex gap-1.5 sm:bottom-9 sm:right-10 lg:right-14">
<button type="button" onClick={() => api.current?.gust()} disabled={paused} aria-label="Let a breeze through the curtain" className={pill}>
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M2.5 7.5h9a2.5 2.5 0 1 0-2.5-2.5M2.5 12.5h12a2.5 2.5 0 1 1-2.5 2.5" /></svg>
<span className="hidden sm:inline">Breeze</span>
</button>
<button type="button" onClick={togglePause} aria-pressed={paused} aria-label="Pause the motion" className={pill}>
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="currentColor">{paused ? <path d="M6 3.5v13l10.5-6.5z" /> : <path d="M5 3.5h3.5v13H5zM11.5 3.5H15v13h-3.5z" />}</svg>
<span className="hidden sm:inline">{paused ? "Play" : "Pause"}</span>
</button>
</div>
)}
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/sheer-linen-curtain-webgl-background-where-afternoon-sun-and-leaf-shadows-sway-across-a-plaster-wall.json
Neither. It is a single shader computed live each frame, so it stays sharp at any size and weighs a few kilobytes of code instead of megabytes of footage.
Each blocker is blurred by its distance from the wall times the apparent size of the sun. A tree several metres away blurs a lot, so small gaps between leaves become round spots of light, just as they do outdoors.
The canvas is capped at roughly 0.9 million pixels, the device pixel ratio is held to 1.5, and the loop measures its own frame time and lowers resolution if frames run long. It stops entirely when scrolled off screen or when the tab is hidden.
Yes. The left side of the frame is the shaded wall, so dark text reads well there. If you move the copy to the right, move the window by changing the numbers in the `win` line so the sunny patch sits elsewhere.
Set the `sway` factor inside `folds` to zero. The curtain then hangs still in its folds while the tree keeps moving in the breeze.
Related: Ray-Marched Glass Ribbon WebGL Background with Dispersion and Rainbow Caustics on a Sunlit Wall That Parts Around the Pointer, Arched Stained Glass Window WebGL Background That Pours Coloured Light Across a Stone Floor as the Sun Moves, Sunlit Print Table Photo Gallery in WebGL Where the Chosen Print Lifts off Travertine into Focus Through Window Light and Leaf Shadows.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.