Window Light Drift
Afternoon sun through a window, crossing a plaster wall over ninety seconds — and your pointer casts a shadow in it.
- Category
- Backgrounds
- Added
- 2026-09-25
- Deps
- none
- Updated
- 2026-09-25
Props
"use client";
import { useEffect, useRef } from "react";
/**
* WindowLightDrift — afternoon sun through a window, drifting across a wall.
*
* One slanted shaft of light, broken by the shadows of the window bars, crosses
* the wall and back over a minute and a half, and breathes very slightly as if
* clouds were passing. It is a background for a café, a salon or a studio: a
* room, not a graphic. Your pointer is a hand in the light — it casts a soft
* shadow on the wall, and only where the light falls.
*
* Everything is drawn on one canvas. The light is built on an offscreen layer
* (a gradient-edged shaft, cut by the bars and the pointer shadow with
* destination-out compositing), laid over the wall, then dusted with a fixed
* grain tile so the wall reads as plaster rather than a flat fill. The loop is
* time-based, sleeps off-screen and in hidden tabs, and under
* prefers-reduced-motion it draws one still frame — the pointer shadow still
* works there, because the reader is driving it.
*
* Place it as the FIRST child of a position: relative section. Pointer events
* are read from that parent, so copy and buttons above it never block the
* shadow.
*/
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function hex(v: string, fallback: string) {
return HEX.test(v) ? v : fallback;
}
function rgba(colour: string, alpha: number) {
let h = colour.slice(1);
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const n = parseInt(h, 16);
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + alpha + ")";
}
/** A 160px tile of fine grain, drawn once and repeated. */
function makeGrain() {
const tile = document.createElement("canvas");
tile.width = 160;
tile.height = 160;
const c = tile.getContext("2d");
if (!c) return null;
const img = c.createImageData(160, 160);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = 90 + Math.floor(Math.random() * 120);
d[i] = v;
d[i + 1] = v;
d[i + 2] = v;
d[i + 3] = 255;
}
c.putImageData(img, 0, 0);
return tile;
}
export function WindowLightDrift({
wall = "#151412",
light = "#f4d9a6",
angle = 22,
bars = 2,
speed = 1,
grain = 0.5,
shadow = true,
className = "",
}: {
/** Wall colour. Hex only. */
wall?: string;
/** Colour of the light. Hex only — warm for afternoon, blue-white for a north window. */
light?: string;
/** Slant of the shaft in degrees, 0–40. */
angle?: number;
/** Vertical window bars crossing the shaft, 0–4. */
bars?: number;
/** Drift speed multiplier. 1 crosses the wall and back in 90 seconds; 0 holds still. */
speed?: number;
/** Plaster grain, 0–1. */
grain?: number;
/** The pointer casts a soft shadow where it crosses the light. */
shadow?: boolean;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Props are read by the render loop through a ref, so a change adjusts the
// next frame instead of tearing the loop down. Synced from an effect.
const opts = useRef({ wall, light, angle, bars, speed, grain, shadow });
const refresh = useRef<() => void>(() => {});
useEffect(() => {
opts.current = { wall, light, angle, bars, speed, grain, shadow };
refresh.current();
}, [wall, light, angle, bars, speed, grain, shadow]);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas ? canvas.parentElement : null;
const ctx = canvas ? canvas.getContext("2d") : null;
if (!canvas || !host || !ctx) return;
const layer = document.createElement("canvas");
const lctx = layer.getContext("2d");
if (!lctx) return;
const grainTile = makeGrain();
const grainPattern = grainTile ? ctx.createPattern(grainTile, "repeat") : null;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
let W = 0;
let H = 0;
let dpr = 1;
let raf = 0;
let onScreen = true;
let hidden = document.visibilityState === "hidden";
let last = 0;
let t = 0; // seconds of drift, only advanced while motion is allowed
const pointer = { x: 0, y: 0, tx: 0, ty: 0, a: 0, ta: 0 };
function layout() {
// Layout size, not the bounding rect: the rect includes ancestor
// transforms, so inside a scaled card the light would fill only part of
// the box, and a ResizeObserver never re-fires to correct it.
W = host!.clientWidth;
H = host!.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
// Keep the backing store under ~8 megapixels on huge screens.
while (W * H * dpr * dpr > 8e6 && dpr > 1) dpr = Math.max(1, dpr - 0.25);
canvas!.width = layer.width = Math.max(1, Math.round(W * dpr));
canvas!.height = layer.height = Math.max(1, Math.round(H * dpr));
canvas!.style.width = W + "px";
canvas!.style.height = H + "px";
}
function paintLight() {
const o = opts.current;
const c = hex(o.light, "#f4d9a6");
const T = 90 / Math.max(0.05, o.speed);
const sway = Math.sin((t / T) * Math.PI * 2);
const breathe = 0.84 + 0.16 * Math.sin((t / 41) * Math.PI * 2 + 1);
const sw = Math.min(W * 0.46, Math.max(160, H * 0.75));
const cx = W * 0.5 + sway * Math.max(0, W * 0.5 - sw * 0.35);
const k = Math.tan((Math.max(0, Math.min(40, o.angle)) * Math.PI) / 180);
lctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
lctx!.globalCompositeOperation = "source-over";
lctx!.clearRect(0, 0, W, H);
// The shaft: a soft-edged band in a space skewed about the wall's middle.
lctx!.transform(1, 0, -k, 1, k * H * 0.5, 0);
const g = lctx!.createLinearGradient(cx - sw / 2, 0, cx + sw / 2, 0);
g.addColorStop(0, rgba(c, 0));
g.addColorStop(0.16, rgba(c, breathe));
g.addColorStop(0.84, rgba(c, breathe));
g.addColorStop(1, rgba(c, 0));
lctx!.fillStyle = g;
lctx!.fillRect(cx - sw / 2, -H * 0.2, sw, H * 1.4);
// Fade at the top and the floor, so the shaft does not read as a stripe.
lctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
lctx!.globalCompositeOperation = "destination-in";
const v = lctx!.createLinearGradient(0, 0, 0, H);
v.addColorStop(0, "rgba(0,0,0,0.5)");
v.addColorStop(0.35, "rgba(0,0,0,1)");
v.addColorStop(0.78, "rgba(0,0,0,1)");
v.addColorStop(1, "rgba(0,0,0,0.25)");
lctx!.fillStyle = v;
lctx!.fillRect(0, 0, W, H);
// The window bars: cut out of the light, in the skewed space.
lctx!.globalCompositeOperation = "destination-out";
lctx!.transform(1, 0, -k, 1, k * H * 0.5, 0);
const n = Math.max(0, Math.min(4, Math.round(o.bars)));
const bw = sw * 0.045;
for (let i = 1; i <= n; i++) {
const bx = cx - sw / 2 + (sw * i) / (n + 1);
const bg = lctx!.createLinearGradient(bx - bw, 0, bx + bw, 0);
bg.addColorStop(0, "rgba(0,0,0,0)");
bg.addColorStop(0.38, "rgba(0,0,0,0.9)");
bg.addColorStop(0.62, "rgba(0,0,0,0.9)");
bg.addColorStop(1, "rgba(0,0,0,0)");
lctx!.fillStyle = bg;
lctx!.fillRect(bx - bw, -H * 0.2, bw * 2, H * 1.4);
}
// The transom: one horizontal bar, level on the wall.
lctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
const ty = H * 0.44;
const th = Math.max(5, H * 0.012);
const tg = lctx!.createLinearGradient(0, ty - th, 0, ty + th);
tg.addColorStop(0, "rgba(0,0,0,0)");
tg.addColorStop(0.4, "rgba(0,0,0,0.88)");
tg.addColorStop(0.6, "rgba(0,0,0,0.88)");
tg.addColorStop(1, "rgba(0,0,0,0)");
lctx!.fillStyle = tg;
lctx!.fillRect(0, ty - th, W, th * 2);
// The hand: a soft shadow that only exists where there is light to block.
if (pointer.a > 0.002) {
const r = Math.max(60, Math.min(W, H) * 0.16);
const sg = lctx!.createRadialGradient(pointer.x, pointer.y, 0, pointer.x, pointer.y, r);
sg.addColorStop(0, "rgba(0,0,0," + 0.62 * pointer.a + ")");
sg.addColorStop(0.55, "rgba(0,0,0," + 0.3 * pointer.a + ")");
sg.addColorStop(1, "rgba(0,0,0,0)");
lctx!.fillStyle = sg;
lctx!.fillRect(pointer.x - r, pointer.y - r, r * 2, r * 2);
}
lctx!.globalCompositeOperation = "source-over";
}
function draw() {
if (!W || !H) return;
const o = opts.current;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx!.globalCompositeOperation = "source-over";
ctx!.globalAlpha = 1;
ctx!.fillStyle = hex(o.wall, "#151412");
ctx!.fillRect(0, 0, W, H);
paintLight();
ctx!.drawImage(layer, 0, 0, W, H);
if (grainPattern && o.grain > 0) {
// Device pixels, so the grain stays fine on a retina screen.
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.globalCompositeOperation = "overlay";
ctx!.globalAlpha = Math.min(1, o.grain) * 0.34;
ctx!.fillStyle = grainPattern;
ctx!.fillRect(0, 0, canvas!.width, canvas!.height);
ctx!.globalAlpha = 1;
ctx!.globalCompositeOperation = "source-over";
}
}
function frame(now: number) {
raf = 0;
// Time-based and clamped, so a long pause never jumps the light.
const dt = last ? Math.min(0.1, (now - last) / 1000) : 1 / 60;
last = now;
if (!reduce.matches) t += dt;
const kp = 1 - Math.exp(-dt / 0.22);
pointer.x += (pointer.tx - pointer.x) * kp;
pointer.y += (pointer.ty - pointer.y) * kp;
pointer.a += (pointer.ta - pointer.a) * kp;
draw();
const settling =
Math.abs(pointer.a - pointer.ta) > 0.003 ||
(pointer.ta > 0 && (Math.abs(pointer.tx - pointer.x) > 0.4 || Math.abs(pointer.ty - pointer.y) > 0.4));
const ambient = !reduce.matches && opts.current.speed > 0;
if (onScreen && !hidden && (ambient || settling)) raf = requestAnimationFrame(frame);
else last = 0;
}
function start() {
if (!raf) raf = requestAnimationFrame(frame);
}
refresh.current = () => {
draw();
start();
};
function toLocal(e: PointerEvent) {
const rect = host!.getBoundingClientRect();
// Back out any ancestor scale: rect is projected, W/H are layout.
return {
x: ((e.clientX - rect.left) * W) / Math.max(1, rect.width),
y: ((e.clientY - rect.top) * H) / Math.max(1, rect.height),
};
}
function onMove(e: PointerEvent) {
if (!opts.current.shadow) return;
const p = toLocal(e);
if (pointer.ta === 0) {
// First entry: appear in place rather than sweeping in from the corner.
pointer.x = p.x;
pointer.y = p.y;
}
pointer.tx = p.x;
pointer.ty = p.y;
pointer.ta = 1;
start();
}
function onLeave() {
pointer.ta = 0;
start();
}
function onVisibility() {
hidden = document.visibilityState === "hidden";
if (!hidden) start();
}
function onReduce() {
start();
}
layout();
draw();
const ro = new ResizeObserver(() => {
layout();
draw();
});
ro.observe(host);
const io = new IntersectionObserver((entries) => {
onScreen = entries[entries.length - 1].isIntersecting;
if (onScreen) start();
});
io.observe(host);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeave);
document.addEventListener("visibilitychange", onVisibility);
reduce.addEventListener("change", onReduce);
start();
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
reduce.removeEventListener("change", onReduce);
refresh.current = () => {};
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={"pointer-events-none absolute inset-0 block " + className}
/>
);
}
About this background
Most animated backgrounds are graphics: particles, meshes, gradients that swim. This one is a room. A single shaft of afternoon light comes through a window off to one side and lies across a plaster wall, cut by the shadows of the window bars, and over a minute and a half it drifts across and back the way sun does. Its brightness breathes very slightly, as if cloud were passing. Because it is something everyone has watched on a real wall, it sits behind a headline without pulling the eye, which is the whole job of a background on a café, salon or studio site. The one interaction is physical: the pointer is a hand in the light and casts a soft shadow, but only where the light actually falls, because the shadow is cut out of the light layer rather than painted on top. It is a single canvas with an offscreen light layer, a fixed grain tile for the plaster, and a time-based loop that sleeps off-screen. Under reduced motion it holds one still frame, and the shadow still works, since the reader is driving it.
Curator’s note
A room, not a graphic. Most animated backgrounds compete with the headline; light on a wall is the one thing a visitor already knows how to ignore.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Pointer Warp Dot Field
backgroundsA canvas dot grid that bulges and brightens away from the cursor.
Signal Field
backgroundsA machined dot grid lit by slow waves of light, with a pointer lens and click ripples.
Reaction Diffusion Plate
backgroundsA live Gray-Scott solver as a background plate — coral, mitosis and maze are the same two equations at different feed and kill rates.