Backgrounds
WindowBlinds
Late sun through a slatted blind, thrown slanted across a wall with a plant's shadow in it. The sun creeps, the slats tilt, and the pointer works the cord.
Download
- React
- Tailwind CSS v4
components/ui/window-blinds.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type WindowBlindsProps = {
children?: ReactNode;
className?: string;
/** Number of slats in the light. */
bands?: number;
/** Colour of the wall in shade. */
wall?: string;
/** Colour of the sunlight. */
light?: string;
/** Show the shadow of a plant in the light. */
plant?: boolean;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
/* One leaf as a lens from the stem out to its tip. */
function leaf(g: CanvasRenderingContext2D, x: number, y: number, angle: number, size: number) {
g.save();
g.translate(x, y);
g.rotate(angle);
g.beginPath();
g.moveTo(0, 0);
g.quadraticCurveTo(size * 0.5, -size * 0.2, size, 0);
g.quadraticCurveTo(size * 0.5, size * 0.2, 0, 0);
g.fill();
g.restore();
}
/* WindowBlinds: late sun through a slatted blind, thrown slanted across a wall with the shadow of
a plant in it. The sun creeps, the slats tilt on their own, and the pointer works the cord. */
export function WindowBlinds({
children,
className = "",
bands = 14,
wall = "#2c211b",
light = "#ffc97a",
plant = true,
speed = 1,
}: WindowBlindsProps) {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let time = 0;
let open = 0.62;
let cord: number | null = null;
const draw = () => {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = wall;
ctx.fillRect(0, 0, w, h);
// The whole patch of light is sheared and turned, the way sun lands on a wall.
const creep = still ? 0 : Math.sin(time * 0.06) * w * 0.05;
const patchW = Math.max(w * 0.66, h * 0.9);
const patchH = h * 1.7;
const pitch = patchH / bands;
ctx.save();
ctx.translate(w * 0.63 + creep, h * 0.5);
ctx.rotate(-0.1);
ctx.transform(1, 0, -0.42, 1, 0, 0);
const sun = ctx.createLinearGradient(-patchW / 2, 0, patchW / 2, 0);
sun.addColorStop(0, `${light}66`);
sun.addColorStop(0.55, `${light}e6`);
sun.addColorStop(1, light);
ctx.fillStyle = sun;
const bar = patchW * 0.035;
for (let i = 0; i < bands; i++) {
const y = -patchH / 2 + i * pitch;
ctx.fillRect(-patchW / 2, y, patchW / 2 - bar, pitch * open);
ctx.fillRect(bar, y, patchW / 2 - bar, pitch * open);
}
ctx.restore();
if (plant) {
const sway = still ? 0 : Math.sin(time * 0.7) * 0.05 + Math.sin(time * 1.9) * 0.015;
const stem = Math.min(w, h) * 0.62;
ctx.save();
ctx.translate(w * 0.86, h * 1.04);
ctx.rotate(-0.28 + sway);
ctx.fillStyle = wall;
ctx.strokeStyle = wall;
ctx.globalAlpha = 0.9;
ctx.lineCap = "round";
ctx.lineWidth = stem * 0.015;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(stem * 0.06, -stem * 0.5, 0, -stem);
ctx.stroke();
for (let i = 0; i < 9; i++) {
const t = 0.2 + i * 0.09;
const y = -stem * t;
const x = stem * 0.12 * t * (1 - t) * 2;
const size = stem * (0.34 - t * 0.17);
leaf(ctx, x, y, -0.55 + sway * 2, size);
leaf(ctx, x, y, -Math.PI + 0.55 - sway * 2, size);
}
leaf(ctx, 0, -stem, -Math.PI / 2, stem * 0.16);
ctx.restore();
}
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
time += dt;
const aim = cord ?? 0.5 + 0.24 * Math.sin(time * 0.22);
open += (aim - open) * Math.min(1, dt * 5);
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (still || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
// A quarter of the size on purpose: stretched back up, every shadow edge comes out soft.
canvas.width = Math.max(1, Math.round(w / 4));
canvas.height = Math.max(1, Math.round(h / 4));
ctx.setTransform(1 / 4, 0, 0, 1 / 4, 0, 0);
draw();
};
const pull = (e: PointerEvent) => {
const box = canvas.getBoundingClientRect();
cord = 0.12 + 0.76 * (1 - (e.clientY - box.top) / box.height);
};
const release = () => {
cord = null;
};
// Redraw on size change; only animate while on screen.
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
ro.observe(canvas);
io.observe(canvas);
if (!still) {
root.addEventListener("pointermove", pull);
root.addEventListener("pointerleave", release);
}
return () => {
stop();
ro.disconnect();
io.disconnect();
root.removeEventListener("pointermove", pull);
root.removeEventListener("pointerleave", release);
};
}, [bands, wall, light, plant, speed]);
return (
<div ref={rootRef} className={`relative overflow-hidden ${className}`} style={{ background: wall }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/window-blinds.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/window-blinds-demo.tsx
import { WindowBlinds } from "@/components/ui/window-blinds"; export default function WindowBlindsExample() { return ( <WindowBlinds className="h-full"> <p className="absolute bottom-5 left-6 rounded bg-[#2c211b] px-3 py-1 text-2xl font-medium text-[#ffe3b8]">Late afternoon</p> </WindowBlinds> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| bands | number | 14 | Number of slats in the light. |
| wall | string | "#2c211b" | Colour of the wall in shade. |
| light | string | "#ffc97a" | Colour of the sunlight. Use 6-digit hex. |
| plant | boolean | true | Show the shadow of a plant in the light. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |