🕸️ 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";
3
4/* ============================================================
5 INK CLOTH — a Verlet-integration physics simulation, from
6 scratch, no engine. A cloth of ink-dots you can grab and tear.
7 The whole simulation is ~70 lines of math:
8 1. verlet step (position += position - oldPosition)
9 2. constraint relaxation (pull linked points together)
10 Drag to move · fast drag tears threads.
11 ============================================================ */
12
13const COLS = 26;
14const ROWS = 16;
15const GAP = 18;
16
17export default function Physics() {
18 const ref = useRef(null);
19
20 useEffect(() => {
21 const canvas = ref.current;
22 const ctx = canvas.getContext("2d");
23 let raf = 0;
24
25 // --- build cloth grid ---
26 const points = [];
27 const links = [];
28 for (let y = 0; y <= ROWS; y++) {
29 for (let x = 0; x <= COLS; x++) {
30 points.push({
31 x: x * GAP,
32 y: y * GAP,
33 ox: x * GAP,
34 oy: y * GAP,
35 pinned: y === 0 && x % 3 === 0, // pin every 3rd point of top row
36 });
37 if (x > 0)
38 links.push({ a: y * (COLS + 1) + x - 1, b: y * (COLS + 1) + x, alive: true });
39 if (y > 0)
40 links.push({ a: (y - 1) * (COLS + 1) + x, b: y * (COLS + 1) + x, alive: true });
41 }
42 }
43
44 let w = 0, h = 0, offX = 0;
45 const resize = () => {
46 const dpr = Math.min(window.devicePixelRatio || 1, 2);
47 w = canvas.clientWidth;
48 h = canvas.clientHeight;
49 canvas.width = w * dpr;
50 canvas.height = h * dpr;
51 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
52 offX = (w - COLS * GAP) / 2;
53 };
54
55 const mouse = { x: -999, y: -999, px: -999, py: -999, down: false };
56 const toLocal = (e) => {
57 const b = canvas.getBoundingClientRect();
58 return { x: e.clientX - b.left - offX, y: e.clientY - b.top - 30 };
59 };
60 const onDown = (e) => { mouse.down = true; const p = toLocal(e); mouse.x = p.x; mouse.y = p.y; };
61 const onMove = (e) => { const p = toLocal(e); mouse.px = mouse.x; mouse.py = mouse.y; mouse.x = p.x; mouse.y = p.y; };
62 const onUp = () => { mouse.down = false; };
63
64 const step = () => {
65 // verlet integration
66 for (const p of points) {
67 if (p.pinned) continue;
68 const vx = (p.x - p.ox) * 0.985; // slight damping
69 const vy = (p.y - p.oy) * 0.985;
70 p.ox = p.x;
71 p.oy = p.y;
72 p.x += vx;
73 p.y += vy + 0.42; // gravity
74
75 // mouse interaction: drag pulls, fast drag tears
76 if (mouse.down) {
77 const dx = p.x - mouse.x;
78 const dy = p.y - mouse.y;
79 const d = Math.hypot(dx, dy);
80 if (d < 36) {
81 p.x += (mouse.x - mouse.px) * 0.9;
82 p.y += (mouse.y - mouse.py) * 0.9;
83 }
84 }
85 }
86 // relax constraints (2 iterations = stable & fast)
87 for (let it = 0; it < 2; it++) {
88 for (const l of links) {
89 if (!l.alive) continue;
90 const A = points[l.a], B = points[l.b];
91 const dx = B.x - A.x, dy = B.y - A.y;
92 const d = Math.hypot(dx, dy) || 0.0001;
93 if (d > GAP * 4.6) { l.alive = false; continue; } // tear!
94 const diff = ((d - GAP) / d) * 0.5;
95 const ox = dx * diff, oy = dy * diff;
96 if (!A.pinned) { A.x += ox; A.y += oy; }
97 if (!B.pinned) { B.x -= ox; B.y -= oy; }
98 }
99 }
100 };
101
102 const draw = () => {
103 step();
104 ctx.clearRect(0, 0, w, h);
105 ctx.save();
106 ctx.translate(offX, 30);
107 // threads
108 ctx.lineWidth = 1.1;
109 for (const l of links) {
110 if (!l.alive) continue;
111 const A = points[l.a], B = points[l.b];
112 const stretch = Math.min(1, (Math.hypot(B.x - A.x, B.y - A.y) - GAP) / (GAP * 3));
113 ctx.strokeStyle = `oklch(${78 - stretch * 30}% ${0.12 + stretch * 0.14} ${300 - stretch * 280})`;
114 ctx.beginPath();
115 ctx.moveTo(A.x, A.y);
116 ctx.lineTo(B.x, B.y);
117 ctx.stroke();
118 }
119 // pins
120 ctx.fillStyle = "#ffa500";
121 for (const p of points)
122 if (p.pinned) {
123 ctx.beginPath();
124 ctx.arc(p.x, p.y, 3.4, 0, Math.PI * 2);
125 ctx.fill();
126 }
127 ctx.restore();
128 raf = requestAnimationFrame(draw);
129 };
130
131 resize();
132 draw();
133 window.addEventListener("resize", resize);
134 canvas.addEventListener("pointerdown", onDown);
135 canvas.addEventListener("pointermove", onMove);
136 window.addEventListener("pointerup", onUp);
137 return () => {
138 cancelAnimationFrame(raf);
139 window.removeEventListener("resize", resize);
140 canvas.removeEventListener("pointerdown", onDown);
141 canvas.removeEventListener("pointermove", onMove);
142 window.removeEventListener("pointerup", onUp);
143 };
144 }, []);
145
146 return (
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">
150 drag gently to ripple · drag fast to tear the threads
151 </p>
152 </div>
153 );
154}
155