← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-tearable-cloth to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-autoplayA pretend hand slashes the cloth every few seconds. It waits while someone is using it.
data-colorCloth colour. Default #c084fc.
data-spacingGap between threads in px. Smaller is finer, but slower. Default fits about 30 across, 7 to 16.
data-windWind strength. Default 1; 0 hangs still.
data-regrowTime 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.

1 · Snippet
<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>
2 · Your HTML
<div data-tearable-cloth data-autoplay style="height: 360px; background: #1a1033"></div>