🕸️ Canvas simulation
Ink Cloth
A tearable cloth simulation with no physics engine — two short loops of math. The foundation of every springy, organic, alive-feeling interaction.
Use it for: Playful 404s · hero toys · game prototypes
drag gently to ripple · drag fast to tear the threads
The complete source
This is the exact file rendering the demo above — a single self-contained React client component with its styles inline. Copy or download it, drop it into any React / Next.js project, and it runs. No extra dependencies.
physics.jsx
1"use client";2import { useEffect, useRef } from "react";34/* ============================================================5INK CLOTH — a Verlet-integration physics simulation, from6scratch, no engine. A cloth of ink-dots you can grab and tear.7The whole simulation is ~70 lines of math:81. verlet step (position += position - oldPosition)92. constraint relaxation (pull linked points together)10Drag to move · fast drag tears threads.11============================================================ */1213const COLS = 26;14const ROWS = 16;15const GAP = 18;1617export default function Physics() {18const ref = useRef(null);1920useEffect(() => {21const canvas = ref.current;22const ctx = canvas.getContext("2d");23let raf = 0;2425// --- build cloth grid ---26const points = [];27const links = [];28for (let y = 0; y <= ROWS; y++) {29for (let x = 0; x <= COLS; x++) {30points.push({31x: x * GAP,32y: y * GAP,33ox: x * GAP,34oy: y * GAP,35pinned: y === 0 && x % 3 === 0, // pin every 3rd point of top row36});37if (x > 0)38links.push({ a: y * (COLS + 1) + x - 1, b: y * (COLS + 1) + x, alive: true });39if (y > 0)40links.push({ a: (y - 1) * (COLS + 1) + x, b: y * (COLS + 1) + x, alive: true });41}42}4344let w = 0, h = 0, offX = 0;45const resize = () => {46const dpr = Math.min(window.devicePixelRatio || 1, 2);47w = canvas.clientWidth;48h = canvas.clientHeight;49canvas.width = w * dpr;50canvas.height = h * dpr;51ctx.setTransform(dpr, 0, 0, dpr, 0, 0);52offX = (w - COLS * GAP) / 2;53};5455const mouse = { x: -999, y: -999, px: -999, py: -999, down: false };56const toLocal = (e) => {57const b = canvas.getBoundingClientRect();58return { x: e.clientX - b.left - offX, y: e.clientY - b.top - 30 };59};60const onDown = (e) => { mouse.down = true; const p = toLocal(e); mouse.x = p.x; mouse.y = p.y; };61const onMove = (e) => { const p = toLocal(e); mouse.px = mouse.x; mouse.py = mouse.y; mouse.x = p.x; mouse.y = p.y; };62const onUp = () => { mouse.down = false; };6364const step = () => {65// verlet integration66for (const p of points) {67if (p.pinned) continue;68const vx = (p.x - p.ox) * 0.985; // slight damping69const vy = (p.y - p.oy) * 0.985;70p.ox = p.x;71p.oy = p.y;72p.x += vx;73p.y += vy + 0.42; // gravity7475// mouse interaction: drag pulls, fast drag tears76if (mouse.down) {77const dx = p.x - mouse.x;78const dy = p.y - mouse.y;79const d = Math.hypot(dx, dy);80if (d < 36) {81p.x += (mouse.x - mouse.px) * 0.9;82p.y += (mouse.y - mouse.py) * 0.9;83}84}85}86// relax constraints (2 iterations = stable & fast)87for (let it = 0; it < 2; it++) {88for (const l of links) {89if (!l.alive) continue;90const A = points[l.a], B = points[l.b];91const dx = B.x - A.x, dy = B.y - A.y;92const d = Math.hypot(dx, dy) || 0.0001;93if (d > GAP * 4.6) { l.alive = false; continue; } // tear!94const diff = ((d - GAP) / d) * 0.5;95const ox = dx * diff, oy = dy * diff;96if (!A.pinned) { A.x += ox; A.y += oy; }97if (!B.pinned) { B.x -= ox; B.y -= oy; }98}99}100};101102const draw = () => {103step();104ctx.clearRect(0, 0, w, h);105ctx.save();106ctx.translate(offX, 30);107// threads108ctx.lineWidth = 1.1;109for (const l of links) {110if (!l.alive) continue;111const A = points[l.a], B = points[l.b];112const stretch = Math.min(1, (Math.hypot(B.x - A.x, B.y - A.y) - GAP) / (GAP * 3));113ctx.strokeStyle = `oklch(${78 - stretch * 30}% ${0.12 + stretch * 0.14} ${300 - stretch * 280})`;114ctx.beginPath();115ctx.moveTo(A.x, A.y);116ctx.lineTo(B.x, B.y);117ctx.stroke();118}119// pins120ctx.fillStyle = "#ffa500";121for (const p of points)122if (p.pinned) {123ctx.beginPath();124ctx.arc(p.x, p.y, 3.4, 0, Math.PI * 2);125ctx.fill();126}127ctx.restore();128raf = requestAnimationFrame(draw);129};130131resize();132draw();133window.addEventListener("resize", resize);134canvas.addEventListener("pointerdown", onDown);135canvas.addEventListener("pointermove", onMove);136window.addEventListener("pointerup", onUp);137return () => {138cancelAnimationFrame(raf);139window.removeEventListener("resize", resize);140canvas.removeEventListener("pointerdown", onDown);141canvas.removeEventListener("pointermove", onMove);142window.removeEventListener("pointerup", onUp);143};144}, []);145146return (147<div className="relative rounded-3xl border border-white/15 overflow-hidden" style={{ background: "#0a0a12" }}>148<canvas ref={ref} className="w-full h-[60svh] block touch-none cursor-grab active:cursor-grabbing" />149<p className="absolute bottom-3 left-1/2 -translate-x-1/2 text-[1.25rem] text-white/55 bg-black/40 px-4 py-1 rounded-full whitespace-nowrap">150drag gently to ripple · drag fast to tear the threads151</p>152</div>153);154}155