Drape Background
A pleated curtain in a breeze that you can push and pull, built with React, WebGL and a cloth simulation.
1 exampleFrom KokonutUI (MIT)
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/drape-background.jsonInstall manually
Copy this into
components/kokonut/drape-background.tsx:drape-background.tsx"use client"; /** * @author: @dorianbaffier * @description: Drape Background * @version: 1.0.0 * @date: 2026-10-14 * @license: MIT * @website: https://kokonutui.com * @github: https://github.com/kokonut-labs/kokonutui */ import { type ReactNode, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; export interface DrapeBackgroundProps { className?: string; /** Content shown over the curtain. Pass `null` for a bare background. */ children?: ReactNode; /** Strength of the breeze. 0 lets the curtain hang still. */ wind?: number; /** Depth of the pleats along the top, in px. */ pleat?: number; /** Push the fabric with the cursor and grab it to drag it around. */ interactive?: boolean; } // Simulation, in px and seconds, stepped at a fixed rate so it behaves the // same at any refresh rate. const STEP = 1 / 60; const GRAVITY = 980; const DAMPING = 0.986; const ITERATIONS = 4; // Roughly this many columns across the surface, never finer than MIN_CELL. const COLUMNS = 46; const MIN_CELL = 16; // The top edge is gathered onto a rod: pins sit closer than the cloth // between them, and the slack falls into folds. const GATHER = 0.8; const PLEAT_PERIOD = 4; // How far the hand reaches into the cloth, and how hard it pushes. const HAND_RADIUS = 120; const HAND_PUSH = 7; const HAND_DRAG = 2.4; // Stiffness of the skip-one links that keep folds round instead of creased. const BEND = 0.35; // Perspective: distance from the eye to the screen plane, in px. const FOCAL = 1400; // Below this much movement per step, with no wind, the cloth is asleep. const REST = 0.02; interface Cloth { cols: number; rows: number; rest: number; pos: Float32Array; prev: Float32Array; pinned: Uint8Array; pins: Float32Array; links: Uint32Array; lengths: Float32Array; stiffness: Float32Array; } /** * Links between neighbours hold the weave; skip-one links give it a little * body, so it folds in curves instead of creasing. */ function createLinks(cols: number, rows: number, rest: number) { const links: number[] = []; const lengths: number[] = []; const stiffness: number[] = []; const link = (a: number, b: number, length: number, stiff: number) => { links.push(a, b); lengths.push(length); stiffness.push(stiff); }; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const a = j * cols + i; if (i + 1 < cols && j > 0) { link(a, a + 1, rest, 1); } if (j + 1 < rows) { link(a, a + cols, rest, 1); } if (i + 2 < cols && j > 0) { link(a, a + 2, rest * 2, BEND); } if (j + 2 < rows) { link(a, a + cols * 2, rest * 2, BEND); } } } return { links, lengths, stiffness }; } function createCloth(width: number, height: number, pleat: number): Cloth { const rest = Math.max(MIN_CELL, (width * 1.16) / COLUMNS / GATHER); const spacing = rest * GATHER; const cols = Math.ceil((width * 1.16) / spacing) + 1; const rows = Math.ceil((height * 1.12) / rest) + 1; const left = (width - (cols - 1) * spacing) / 2; const top = -rest * 0.5; const count = cols * rows; const pos = new Float32Array(count * 3); const pinned = new Uint8Array(count); const pins = new Float32Array(cols * 3); // Folds a little uneven, as a hand-gathered curtain hangs. const folds = Array.from({ length: cols }, (_, i) => { let phase = 0; for (let c = 0; c < i; c++) { phase += ((Math.PI * 2) / PLEAT_PERIOD) * (1 + 0.3 * Math.sin(c * 0.9)); } return pleat * Math.sin(phase) * (0.8 + 0.2 * Math.cos(i * 0.37)); }); for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const k = (j * cols + i) * 3; const z = folds[i]; pos[k] = left + i * spacing; pos[k + 1] = top + j * rest; pos[k + 2] = z; if (j === 0) { pinned[i] = 1; pins.set([pos[k], pos[k + 1], z], i * 3); } } } const { links, lengths, stiffness } = createLinks(cols, rows, rest); return { cols, rows, rest, pos, prev: pos.slice(), pinned, pins, links: Uint32Array.from(links), lengths: Float32Array.from(lengths), stiffness: Float32Array.from(stiffness), }; } interface Forces { time: number; wind: number; hand: { x: number; y: number; vx: number; vy: number; on: boolean }; grab: { index: number; x: number; y: number } | null; } const accel: [number, number, number] = [0, 0, 0]; /** Gravity, the breeze and the hand, acting on a point at (x, y). */ function accelerate(x: number, y: number, forces: Forces) { const { time, wind, hand } = forces; accel[0] = 0; accel[1] = GRAVITY; accel[2] = 0; if (wind > 0) { // Gusts: slow sine waves that roll across the curtain and swell. const gust = Math.sin(x * 0.006 + time * 0.9) * 0.6 + Math.sin(x * 0.013 - time * 1.7 + y * 0.004) * 0.4; const swell = 0.6 + 0.4 * Math.sin(time * 0.35); accel[2] += gust * swell * wind * 260 * (y / 600); accel[0] += Math.sin(time * 0.5 + y * 0.003) * wind * 30; } if (hand.on) { const dx = x - hand.x; const dy = y - hand.y; const d2 = dx * dx + dy * dy; if (d2 < HAND_RADIUS * HAND_RADIUS) { const fall = 1 - Math.sqrt(d2) / HAND_RADIUS; const f = fall * fall; // Press the fabric back, and drag it a little along with the hand. accel[2] += f * HAND_PUSH * 1000; accel[0] += f * hand.vx * HAND_DRAG * 60; accel[1] += f * hand.vy * HAND_DRAG * 60; } } return accel; } /** Verlet step: move every free point under its forces. */ function integrate(cloth: Cloth, forces: Forces) { const { pos, prev, pinned } = cloth; const dt2 = STEP * STEP; let motion = 0; for (let n = 0; n < pinned.length; n++) { if (pinned[n]) { continue; } const k = n * 3; const x = pos[k]; const y = pos[k + 1]; const z = pos[k + 2]; const [ax, ay, az] = accelerate(x, y, forces); const vx = (x - prev[k]) * DAMPING; const vy = (y - prev[k + 1]) * DAMPING; const vz = (z - prev[k + 2]) * DAMPING; prev[k] = x; prev[k + 1] = y; prev[k + 2] = z; pos[k] = x + vx + ax * dt2; pos[k + 1] = y + vy + ay * dt2; pos[k + 2] = z + vz + az * dt2; motion += Math.abs(vx) + Math.abs(vy) + Math.abs(vz); } return motion / pinned.length; } function relax(cloth: Cloth, grab: Forces["grab"]) { const { pos, pinned, links, lengths, stiffness, pins } = cloth; for (let it = 0; it < ITERATIONS; it++) { for (let l = 0; l < lengths.length; l++) { const a = links[l * 2]; const b = links[l * 2 + 1]; const ka = a * 3; const kb = b * 3; const dx = pos[kb] - pos[ka]; const dy = pos[kb + 1] - pos[ka + 1]; const dz = pos[kb + 2] - pos[ka + 2]; const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; const diff = (d - lengths[l]) / d; // Bend links only resist being squeezed, so folds stay soft. if (stiffness[l] < 1 && diff > 0) { continue; } // Pinned points don't move; links between two pins aren't made. const wa = 1 - pinned[a]; const wb = 1 - pinned[b]; const s = (diff * stiffness[l]) / (wa + wb); pos[ka] += dx * s * wa; pos[ka + 1] += dy * s * wa; pos[ka + 2] += dz * s * wa; pos[kb] -= dx * s * wb; pos[kb + 1] -= dy * s * wb; pos[kb + 2] -= dz * s * wb; } if (grab) { const k = grab.index * 3; pos[k] = grab.x; pos[k + 1] = grab.y; } for (let i = 0; i < cloth.cols; i++) { pos.set(pins.subarray(i * 3, i * 3 + 3), i * 3); } } } /** Interleaved position and normal for every point, for the GPU. */ function writeVertices(cloth: Cloth, out: Float32Array) { const { cols, rows, pos } = cloth; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const n = j * cols + i; const l = (j * cols + Math.max(i - 1, 0)) * 3; const r = (j * cols + Math.min(i + 1, cols - 1)) * 3; const u = (Math.max(j - 1, 0) * cols + i) * 3; const d = (Math.min(j + 1, rows - 1) * cols + i) * 3; const ax = pos[r] - pos[l]; const ay = pos[r + 1] - pos[l + 1]; const az = pos[r + 2] - pos[l + 2]; const bx = pos[d] - pos[u]; const by = pos[d + 1] - pos[u + 1]; const bz = pos[d + 2] - pos[u + 2]; // Down × across points out of the screen, towards the viewer. let nx = by * az - bz * ay; let ny = bz * ax - bx * az; let nz = bx * ay - by * ax; const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1; nx /= len; ny /= len; nz /= len; const o = n * 6; out[o] = pos[n * 3]; out[o + 1] = pos[n * 3 + 1]; out[o + 2] = pos[n * 3 + 2]; out[o + 3] = nx; out[o + 4] = ny; out[o + 5] = nz; } } } const VERTEX = ` attribute vec3 aPos; attribute vec3 aNormal; uniform vec2 uSize; uniform float uFocal; varying vec3 vNormal; void main() { float s = uFocal / (uFocal + aPos.z); vec2 p = uSize * 0.5 + (aPos.xy - uSize * 0.5) * s; gl_Position = vec4(p.x / uSize.x * 2.0 - 1.0, 1.0 - p.y / uSize.y * 2.0, clamp(aPos.z / 4000.0, -1.0, 1.0), 1.0); vNormal = aNormal; }`; // Soft wrapped light from the top left and a satin sheen, blended between // two theme colors so the curtain sits in the page in light and dark mode. const FRAGMENT = ` precision mediump float; uniform vec3 uLit; uniform vec3 uShade; varying vec3 vNormal; void main() { vec3 n = normalize(vNormal); if (n.z > 0.0) n = -n; vec3 light = normalize(vec3(-0.45, -0.55, -0.7)); float diffuse = max(dot(n, light), 0.0); float wrap = diffuse * 0.8 + 0.2; float sheen = pow(max(dot(reflect(-light, n), vec3(0.0, 0.0, -1.0)), 0.0), 18.0); float t = clamp(wrap * wrap + sheen * 0.15, 0.0, 1.0); gl_FragColor = vec4(mix(uShade, uLit, t), 1.0); }`; function compile(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) { return null; } gl.shaderSource(shader, source); gl.compileShader(shader); return shader; } function createRenderer(gl: WebGLRenderingContext) { const program = gl.createProgram(); const vs = compile(gl, gl.VERTEX_SHADER, VERTEX); const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT); if (!(program && vs && fs)) { return null; } gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { return null; } // biome-ignore lint/correctness/useHookAtTopLevel: a WebGL call, not a React hook. gl.useProgram(program); const vertexBuffer = gl.createBuffer(); const indexBuffer = gl.createBuffer(); const aPos = gl.getAttribLocation(program, "aPos"); const aNormal = gl.getAttribLocation(program, "aNormal"); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.enableVertexAttribArray(aPos); gl.enableVertexAttribArray(aNormal); gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 24, 0); gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 24, 12); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); return { uSize: gl.getUniformLocation(program, "uSize"), uFocal: gl.getUniformLocation(program, "uFocal"), uLit: gl.getUniformLocation(program, "uLit"), uShade: gl.getUniformLocation(program, "uShade"), setMesh(cols: number, rows: number) { const indices = new Uint16Array((cols - 1) * (rows - 1) * 6); let o = 0; for (let j = 0; j < rows - 1; j++) { for (let i = 0; i < cols - 1; i++) { const a = j * cols + i; indices.set([a, a + 1, a + cols, a + 1, a + cols + 1, a + cols], o); o += 6; } } gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); return indices.length; }, destroy() { gl.deleteBuffer(vertexBuffer); gl.deleteBuffer(indexBuffer); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); }, }; } /** Any CSS color (oklch included) as 0-1 RGB, via a 1px canvas. */ function toRgb(color: string, probe: CanvasRenderingContext2D) { probe.clearRect(0, 0, 1, 1); probe.fillStyle = color; probe.fillRect(0, 0, 1, 1); const [r, g, b] = probe.getImageData(0, 0, 1, 1).data; return [r / 255, g / 255, b / 255] as const; } export default function DrapeBackground({ className, children, wind = 1, pleat = 34, interactive = true, }: DrapeBackgroundProps) { const rootRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const litRef = useRef<HTMLSpanElement>(null); const shadeRef = useRef<HTMLSpanElement>(null); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const gl = canvas?.getContext("webgl", { antialias: true }); const probe = document .createElement("canvas") .getContext("2d", { willReadFrequently: true }); if (!(root && canvas && gl && probe)) { return; } const renderer = createRenderer(gl); if (!renderer) { return; } const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; const forces: Forces = { time: 0, wind: reduceMotion ? 0 : wind, hand: { x: 0, y: 0, vx: 0, vy: 0, on: false }, grab: null, }; let width = 0; let height = 0; let cloth: Cloth | null = null; let vertices = new Float32Array(0); let indexCount = 0; let raf = 0; let last = 0; let carry = 0; let quiet = 0; let visible = true; let colorKey = ""; const readColors = () => { const lit = getComputedStyle(litRef.current as Element).color; const shade = getComputedStyle(shadeRef.current as Element).color; if (`${lit}|${shade}` === colorKey) { return false; } colorKey = `${lit}|${shade}`; gl.uniform3fv(renderer.uLit, toRgb(lit, probe)); gl.uniform3fv(renderer.uShade, toRgb(shade, probe)); return true; }; const draw = () => { if (!cloth) { return; } writeVertices(cloth, vertices); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); // biome-ignore lint/suspicious/noBitwiseOperators: WebGL clear flags. gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0); }; const build = () => { const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); width = rect.width; height = rect.height; if (width < 2 || height < 2) { return; } canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(renderer.uSize, width, height); gl.uniform1f(renderer.uFocal, FOCAL); cloth = createCloth(width, height, pleat); vertices = new Float32Array(cloth.cols * cloth.rows * 6); indexCount = renderer.setMesh(cloth.cols, cloth.rows); // Let it fall and settle before the first frame, so it opens hanging. const settleForces = { ...forces, hand: { ...forces.hand, on: false } }; for (let i = 0; i < 180; i++) { settleForces.time = forces.time + i * STEP; integrate(cloth, settleForces); relax(cloth, null); } draw(); }; const frame = (now: number) => { raf = 0; if (!(visible && cloth)) { return; } carry += last ? Math.min((now - last) / 1000, 0.1) : STEP; last = now; let moved = 0; let steps = 0; while (carry >= STEP && steps < 3) { forces.time += STEP; moved = integrate(cloth, forces); relax(cloth, forces.grab); carry -= STEP; steps++; } carry = Math.min(carry, STEP); forces.hand.vx *= 0.8; forces.hand.vy *= 0.8; draw(); quiet = moved < REST ? quiet + 1 : 0; const awake = forces.wind > 0 || forces.hand.on || forces.grab || quiet < 30; if (awake) { raf = requestAnimationFrame(frame); } }; const start = () => { if (!raf && visible && !reduceMotion) { last = 0; quiet = 0; raf = requestAnimationFrame(frame); } }; readColors(); gl.clearColor(0, 0, 0, 0); build(); canvas.style.opacity = "1"; start(); const resizeObserver = new ResizeObserver(() => { build(); start(); }); resizeObserver.observe(canvas); // A tab coming back must not wake a background that's scrolled away. let onScreen = true; const intersection = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; visible = onScreen && document.visibilityState === "visible"; start(); }); intersection.observe(root); const onVisibility = () => { visible = onScreen && document.visibilityState === "visible"; start(); }; document.addEventListener("visibilitychange", onVisibility); // Follow light and dark mode without asking the app for a theme. Only // checked while the background is on screen. const colorTimer = window.setInterval(() => { if (!visible) { return; } if (readColors() && !raf) { draw(); } }, 500); const local = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; /** The point drawn nearest the pointer, within reach. */ const nearest = (x: number, y: number) => { if (!cloth) { return -1; } let best = -1; let bestD = 70 * 70; for (let n = cloth.cols; n < cloth.pinned.length; n++) { const k = n * 3; const s = FOCAL / (FOCAL + cloth.pos[k + 2]); const px = width / 2 + (cloth.pos[k] - width / 2) * s; const py = height / 2 + (cloth.pos[k + 1] - height / 2) * s; const d = (px - x) ** 2 + (py - y) ** 2; if (d < bestD) { bestD = d; best = n; } } return best; }; const onMove = (event: PointerEvent) => { const p = local(event); const hand = forces.hand; if (forces.grab) { forces.grab.x = p.x; forces.grab.y = p.y; } else if (event.pointerType === "mouse") { if (hand.on) { hand.vx = hand.vx * 0.5 + (p.x - hand.x) * 0.5; hand.vy = hand.vy * 0.5 + (p.y - hand.y) * 0.5; } hand.on = true; } hand.x = p.x; hand.y = p.y; start(); }; const onLeave = () => { forces.hand.on = false; }; const onDown = (event: PointerEvent) => { // Clicks on the content (links, buttons) are not meant for the cloth. if ((event.target as HTMLElement).closest("a, button, input, textarea")) { return; } const p = local(event); const index = nearest(p.x, p.y); if (index < 0) { return; } event.preventDefault(); root.setPointerCapture(event.pointerId); forces.grab = { index, ...p }; forces.hand.on = false; root.dataset.grabbing = ""; start(); }; const onUp = () => { forces.grab = null; delete root.dataset.grabbing; }; if (interactive && !reduceMotion) { root.addEventListener("pointermove", onMove); root.addEventListener("pointerleave", onLeave); root.addEventListener("pointerdown", onDown); root.addEventListener("pointerup", onUp); root.addEventListener("pointercancel", onUp); } return () => { cancelAnimationFrame(raf); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); window.clearInterval(colorTimer); root.removeEventListener("pointermove", onMove); root.removeEventListener("pointerleave", onLeave); root.removeEventListener("pointerdown", onDown); root.removeEventListener("pointerup", onUp); root.removeEventListener("pointercancel", onUp); renderer.destroy(); }; }, [wind, pleat, interactive]); return ( <div className={cn( "relative flex min-h-[560px] w-full touch-pan-y items-center justify-center overflow-hidden bg-zinc-100 text-zinc-900 data-[grabbing]:cursor-grabbing dark:bg-zinc-950 dark:text-zinc-100", className )} ref={rootRef} > {/* Theme colors for the shader: the lit face and the deepest fold. */} <span className="hidden text-white dark:text-zinc-800" ref={litRef} /> <span className="hidden text-zinc-300 dark:text-black" ref={shadeRef} /> <div aria-hidden="true" className="pointer-events-none absolute inset-0"> <canvas className="h-full w-full opacity-0 transition-opacity duration-700 ease-out" ref={canvasRef} /> </div> {children === undefined ? <DefaultContent /> : children} </div> ); } function DefaultContent() { return ( <div className="pointer-events-none relative flex max-w-xl flex-col items-center gap-4 px-6 text-center"> <h1 className="font-semibold text-4xl tracking-tight sm:text-5xl"> Behind the curtain </h1> <p className="max-w-sm text-base text-zinc-500 dark:text-zinc-400"> A pleated curtain in a light breeze. Sweep the cursor through it, or grab a fold and pull. </p> </div> ); }
Feedback
Questions, bugs or ideas for Drape Background? Post them here, or reply to someone else. Sign in with GitHub to comment.