Tearable Cloth
A sheet of cloth hangs from a rail and billows in a gusty wind, with folds that darken as it bunches up. Sweep the pointer across it to push it about, and press and drag, or drag a finger, to slash through it. Torn flaps flutter and drop away, and a fresh sheet is hung a few seconds later.
Drag across the cloth to tear it
How to use it
- Paste the snippet just before
</head>. - Add
data-tearable-clothto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-autoplay | A pretend hand slashes the cloth every few seconds. It waits while someone is using it. |
data-color | Cloth colour. Default #c084fc. |
data-spacing | Gap between threads in px. Smaller is finer, but slower. Default fits about 30 across, 7 to 16. |
data-wind | Wind strength. Default 1; 0 hangs still. |
data-regrow | Time after the last tear before a new sheet is hung. Default 3500 ms; 0 never. |
The cloth is a grid of points joined by threads (Verlet integration). Each frame the points fall and are blown about, then every thread is pulled back to its length a few times over. A thread that’s cut, or stretched too far, is removed. It’s paused when off screen, and with reduced motion it hangs still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tearable Cloth: a sheet of cloth hangs from a rail and billows in a
// gusty wind. Sweep the pointer across it to push it about; press and
// drag (or drag a finger) to tear through it. Torn flaps flutter and
// fall, and a fresh sheet is hung a few seconds later. With data-autoplay
// a pretend hand slashes it every so often.
// Usage: <div data-tearable-cloth data-autoplay style="height:360px;background:#1a1033"></div>
// Optional: data-autoplay (tear it by itself every few seconds, pausing
// when someone touches it), data-color (cloth colour, default #c084fc),
// data-spacing (gap between threads in px, default fits about 30 across),
// data-wind (wind strength, default 1), data-regrow (ms after the last
// tear before a new sheet is hung, default 3500; 0 never).
var DEFAULTS = { color: '#c084fc', wind: 1, regrow: 3500 };
var css =
'[data-tearable-cloth]{position:relative;isolation:isolate;overflow:hidden;touch-action:pan-y;cursor:crosshair}' +
'.tcl-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var c = hex.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
// Distance squared from point p to the segment a-b.
function segDist2(px, py, ax, ay, bx, by) {
var dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy;
var t = l2 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0;
var x = ax + dx * t - px, y = ay + dy * t - py;
return x * x + y * y;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'tcl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var base = rgb(d.color || DEFAULTS.color);
var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
var regrow = d.regrow !== undefined ? +d.regrow : DEFAULTS.regrow;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var w = 0, h = 0, dpr = 1, t = 0;
var cols = 0, rows = 0, sp = 0, left = 0, top = 0;
var px, py, ox, oy, pin, linkR, linkD; // points and their links
var torn = 0, lastTear = -1, fadeIn = 0, fadeOut = 0;
var mouse = null, prevMouse = null, cutting = false, touched = -1e9;
var bot = null, botWait = 1500, botCuts = 0;
var shades = [];
for (var s = 0; s < 24; s++) {
var k = 0.5 + s / 23 * 0.75;
shades.push('rgb(' + Math.min(255, Math.round(base[0] * k)) + ',' + Math.min(255, Math.round(base[1] * k)) + ',' +
Math.min(255, Math.round(base[2] * k)) + ')');
}
function hang() {
sp = +d.spacing || Math.max(7, Math.min(16, w / 30));
cols = Math.max(8, Math.floor(w * 0.82 / sp));
rows = Math.max(6, Math.floor(h * 0.68 / sp));
left = (w - (cols - 1) * sp) / 2;
top = Math.max(14, h * 0.1);
var n = cols * rows;
px = new Float32Array(n); py = new Float32Array(n);
ox = new Float32Array(n); oy = new Float32Array(n);
pin = new Uint8Array(n); linkR = new Uint8Array(n); linkD = new Uint8Array(n);
for (var j = 0; j < rows; j++) {
for (var i = 0; i < cols; i++) {
var p = j * cols + i;
px[p] = ox[p] = left + i * sp;
py[p] = oy[p] = top + j * sp;
pin[p] = j === 0 && (i % 3 === 0 || i === cols - 1) ? 1 : 0;
linkR[p] = i < cols - 1 ? 1 : 0;
linkD[p] = j < rows - 1 ? 1 : 0;
}
}
torn = 0; lastTear = -1; fadeIn = 1; botCuts = 0; botWait = 1200;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
hang();
return true;
}
function cut(a, b) {
var r2 = sp * sp * 0.6;
for (var p = 0; p < cols * rows; p++) {
if (linkR[p] && segDist2((px[p] + px[p + 1]) / 2, (py[p] + py[p + 1]) / 2, a.x, a.y, b.x, b.y) < r2) { linkR[p] = 0; tear(); }
if (linkD[p] && segDist2((px[p] + px[p + cols]) / 2, (py[p] + py[p + cols]) / 2, a.x, a.y, b.x, b.y) < r2) { linkD[p] = 0; tear(); }
}
}
function tear() { torn++; lastTear = t; }
// A gentle shove for points near a moving pointer.
function push(a, b) {
var r = sp * 4, dx = b.x - a.x, dy = b.y - a.y;
for (var p = 0; p < cols * rows; p++) {
if (pin[p]) continue;
var ex = px[p] - b.x, ey = py[p] - b.y, d2 = ex * ex + ey * ey;
if (d2 > r * r) continue;
var f = (1 - Math.sqrt(d2) / r) * 0.5;
ox[p] = px[p] - dx * f;
oy[p] = py[p] - dy * f;
}
}
function simulate(dt) {
var n = cols * rows, g = 900 * dt * dt / 1e6;
// Gusts: a slow swell, plus ripples that run along the cloth.
var gust = (0.55 + 0.45 * Math.sin(t * 0.6) * Math.sin(t * 0.23 + 1)) * wind;
for (var p = 0; p < n; p++) {
if (pin[p]) continue;
var i = p % cols, j = (p / cols) | 0;
var wx = gust * (0.3 + 0.7 * Math.sin(t * 2.1 + j * 0.3 - i * 0.22) * Math.sin(t * 0.9 + i * 0.07)) * 380 * dt * dt / 1e6;
var wy = gust * Math.sin(t * 2.6 + i * 0.4 - j * 0.2) * 140 * dt * dt / 1e6;
var vx = (px[p] - ox[p]) * 0.985, vy = (py[p] - oy[p]) * 0.985;
ox[p] = px[p]; oy[p] = py[p];
px[p] += vx + wx; py[p] += vy + g + wy;
}
for (var it = 0; it < 4; it++) {
for (p = 0; p < n; p++) {
if (linkR[p]) relax(p, p + 1);
if (linkD[p]) relax(p, p + cols, true);
}
}
}
function relax(a, b, down) {
var dx = px[b] - px[a], dy = py[b] - py[a], dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
if (dist > sp * 4.5) { if (down) linkD[a] = 0; else linkR[a] = 0; tear(); return; }
var diff = (dist - sp) / dist * 0.5;
var mx = dx * diff, my = dy * diff;
if (!pin[a]) { px[a] += mx; py[a] += my; }
if (!pin[b]) { px[b] -= mx; py[b] -= my; }
if (pin[a] && !pin[b]) { px[b] -= mx; py[b] -= my; }
if (pin[b] && !pin[a]) { px[a] += mx; py[a] += my; }
}
// Lighter where the cloth lies flat and faces the light, darker where it
// bunches into folds. Shaded by the edge a-b along the weave.
function shade(a, b) {
var ux = (px[b] - px[a]) / sp, uy = (py[b] - py[a]) / sp;
var lit = 0.2 + 0.75 * Math.max(0, Math.min(1, ux)) - uy * 0.3;
return shades[Math.max(0, Math.min(23, Math.round(lit * 23)))];
}
function tri(a, b, c, col) {
ctx.fillStyle = col;
ctx.strokeStyle = col;
ctx.beginPath();
ctx.moveTo(px[a], py[a]);
ctx.lineTo(px[b], py[b]);
ctx.lineTo(px[c], py[c]);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// The rail.
ctx.fillStyle = 'rgba(255,255,255,.25)';
ctx.fillRect(left - sp * 1.2, top - 5, (cols - 1) * sp + sp * 2.4, 3);
ctx.globalAlpha = Math.max(0, Math.min(1, 1 - fadeIn)) * (1 - fadeOut);
ctx.lineWidth = 0.6;
ctx.lineJoin = 'round';
for (var j = 0; j < rows - 1; j++) {
for (var i = 0; i < cols - 1; i++) {
var p = j * cols + i, r = p + 1, dn = p + cols, dr = dn + 1;
if (linkR[p] && linkD[p]) tri(p, r, dn, shade(p, r));
if (linkR[dn] && linkD[r]) tri(r, dr, dn, shade(dn, dr));
}
}
ctx.globalAlpha = 1;
// The rings it hangs from.
ctx.fillStyle = 'rgba(255,255,255,.7)';
for (i = 0; i < cols; i++) if (pin[i]) ctx.fillRect(px[i] - 1.5, py[i] - 4, 3, 5);
}
// The pretend hand: a slash across the cloth, every few seconds.
function moveBot(dt) {
if (!bot) {
botWait -= dt;
if (botWait > 0 || botCuts >= 2 || t - touched < 5 || fadeOut > 0 || fadeIn > 0) return;
var y0 = top + rows * sp * (0.25 + Math.random() * 0.4), down = Math.random() < 0.5;
var fromLeft = Math.random() < 0.5;
bot = { x0: fromLeft ? left - sp * 2 : left + cols * sp + sp, y0: y0,
x1: fromLeft ? left + cols * sp * (0.55 + Math.random() * 0.4) : left + cols * sp * (0.05 + Math.random() * 0.4),
y1: y0 + (down ? 1 : -1) * rows * sp * (0.1 + Math.random() * 0.3), k: 0, prev: null };
return;
}
bot.k = Math.min(1, bot.k + dt / 650);
var e = 1 - Math.pow(1 - bot.k, 2);
var cur = { x: bot.x0 + (bot.x1 - bot.x0) * e, y: bot.y0 + (bot.y1 - bot.y0) * e };
if (bot.prev) cut(bot.prev, cur);
bot.prev = cur;
if (bot.k >= 1) { bot = null; botCuts++; botWait = 1400 + Math.random() * 1000; }
}
function tick(dt) {
t += dt / 1000;
if (fadeIn > 0) fadeIn = Math.max(0, fadeIn - dt / 500);
if (mouse && prevMouse && (mouse.x !== prevMouse.x || mouse.y !== prevMouse.y)) {
if (cutting) cut(prevMouse, mouse); else push(prevMouse, mouse);
}
prevMouse = mouse;
if (autoplay) moveBot(dt);
var steps = 2;
for (var s = 0; s < steps; s++) simulate(dt / steps);
// Hang a fresh sheet a while after the last tear.
if (fadeOut > 0) {
fadeOut += dt / 600;
if (fadeOut >= 1) { fadeOut = 0; hang(); }
} else if (regrow > 0 && lastTear >= 0 && (t - lastTear) * 1000 > regrow && !cutting) fadeOut = 0.001;
}
resize();
if (reduced()) {
for (var q = 0; q < 120; q++) { t += 0.016; simulate(16); }
fadeIn = 0;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { fadeIn = 0; draw(); } }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
function at(e) {
var b = el.getBoundingClientRect();
return { x: e.clientX - b.left, y: e.clientY - b.top };
}
el.addEventListener('pointerdown', function (e) {
cutting = true; touched = t; bot = null;
mouse = prevMouse = at(e);
if (e.pointerType !== 'mouse' && el.setPointerCapture) el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType !== 'mouse' && !cutting) return;
mouse = at(e); touched = t;
});
function up() { cutting = false; }
el.addEventListener('pointerup', function (e) { up(); if (e.pointerType !== 'mouse') mouse = prevMouse = null; });
el.addEventListener('pointercancel', function () { up(); mouse = prevMouse = null; });
el.addEventListener('pointerleave', function () { up(); mouse = prevMouse = null; });
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(33, now - last) : 16;
last = now;
tick(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-tearable-cloth]').forEach(function (el) {
if (el._tcl) return;
el._tcl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-tearable-cloth data-autoplay style="height: 360px; background: #1a1033"></div>