← Text effects

Torn Paper Text

Hover over the text and it rips in two along a jagged line, catching for a moment before it gives way, the halves swinging apart to show a rough white edge. Move away and it mends, ready to tear somewhere new.

Torn apart

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-torn-paper-text to a heading. Add data-paper with a colour to put it on a paper label that tears with it.

Options

Add any of these attributes to the same element.

data-paperColour of a paper label behind the text. Default none: just the letters tear.
data-angleHow far the halves swing apart, in degrees. Default 4.
data-autoplayTear and mend by itself every few seconds while nobody is hovering. Handy for demos and touch screens.
data-intervalTime between autoplay tears. Default 3200 ms.

On a touch screen, tap to tear it and tap again to mend it. The line is different every time.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Torn Paper Text: hover over the text and it rips in two along a jagged
    // line, the halves swinging apart with a rough white edge showing. Move
    // away and it mends. Tap it on a touch screen.
    // Usage: <h1 data-torn-paper-text data-paper="#fffaf0">Rip it up</h1>
    // Optional: data-paper (colour of a paper label behind the text, which
    // tears with it; default none, just the letters tear), data-angle (how far
    // the halves swing apart, in degrees; default 4), data-autoplay (tear and
    // mend by itself while nobody is hovering), data-interval (ms between
    // autoplay tears; default 3200).
    var DEFAULTS = { angle: 4, interval: 3200 };

    var css =
      '.tpt-wrap{position:relative;display:inline-block;vertical-align:top}' +
      '.tpt-wrap.tpt-paper{filter:drop-shadow(0 .03em .05em rgba(40,20,10,.28))}' +
      '.tpt-half{display:block;position:relative;transform-origin:0 55%;will-change:transform}' +
      '.tpt-half+.tpt-half{position:absolute;inset:0}' +
      '.tpt-piece{display:block;position:relative}' +
      '.tpt-paper .tpt-piece{padding:.22em .5em;background:var(--tpt-paper)}' +
      '.tpt-rim{position:absolute;inset:0;background:#fff;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var interval = +d.interval || DEFAULTS.interval;
      var paper = d.paper;

      var wrap = document.createElement('span');
      wrap.className = 'tpt-wrap' + (paper ? ' tpt-paper' : '');
      if (paper) wrap.style.setProperty('--tpt-paper', paper);
      var halves = [], pieces = [], rims = [];
      for (var k = 0; k < 2; k++) {
        var half = document.createElement('span');
        half.className = 'tpt-half';
        var rim = document.createElement('span');
        rim.className = 'tpt-rim';
        rim.setAttribute('aria-hidden', 'true');
        var piece = document.createElement('span');
        piece.className = 'tpt-piece';
        if (k === 0) { while (el.firstChild) piece.appendChild(el.firstChild); }
        else { piece.innerHTML = pieces[0].innerHTML; half.setAttribute('aria-hidden', 'true'); }
        if (paper) half.appendChild(rim);
        half.appendChild(piece);
        wrap.appendChild(half);
        halves.push(half); pieces.push(piece); rims.push(rim);
      }
      el.appendChild(wrap);

      // A jagged line from left to right, with the odd bigger tooth.
      function cut() {
        var n = 22, ys = [], y = anime.utils.random(44, 58, 1), drift = anime.utils.random(-10, 10, 1) / n;
        for (var i = 0; i <= n; i++) {
          y += drift;
          var j = Math.random() < 0.18 ? anime.utils.random(-12, 12, 1) : anime.utils.random(-5, 5, 1);
          ys.push(Math.max(25, Math.min(75, y + j)));
        }
        function edge(off, rough) {
          return ys.map(function (v, i) {
            var r = rough ? anime.utils.random(-1.2, 1.2, 1) : 0;
            return (i * 100 / n).toFixed(1) + '% ' + (v + off + r).toFixed(1) + '%';
          });
        }
        var top = 'polygon(0% 0%,100% 0%,' + edge(0).reverse().join(',') + ')';
        var bot = 'polygon(' + edge(-0.8).join(',') + ',100% 100%,0% 100%)';
        // The rims sit just past each edge, so a rough white band shows once torn.
        var topRim = 'polygon(0% 0%,100% 0%,' + edge(2.6, true).reverse().join(',') + ')';
        var botRim = 'polygon(' + edge(-3, true).join(',') + ',100% 100%,0% 100%)';
        [[pieces[0], top], [pieces[1], bot], [rims[0], topRim], [rims[1], botRim]].forEach(function (pair) {
          pair[0].style.clipPath = pair[1];
          pair[0].style.webkitClipPath = pair[1];
        });
      }
      cut();
      if (reduced()) return;

      var state = { p: 0 }, anim = null, torn = false;
      function draw() {
        var p = state.p;
        halves[0].style.transform = 'translate(' + (-0.02 * p).toFixed(3) + 'em,' + (-0.06 * p).toFixed(3) + 'em) rotate(' + (-angle * 0.55 * p).toFixed(2) + 'deg)';
        halves[1].style.transform = 'translate(' + (0.03 * p).toFixed(3) + 'em,' + (0.06 * p).toFixed(3) + 'em) rotate(' + (angle * 0.45 * p).toFixed(2) + 'deg)';
        rims[0].style.opacity = rims[1].style.opacity = Math.min(1, p * 4).toFixed(2);
      }
      function tear() {
        if (torn) return;
        torn = true;
        if (anim) anim.pause();
        if (state.p < 0.02) cut();
        // Rips happen in jerks: a catch, a pause, then it gives way.
        anim = anime.animate(state, {
          p: [{ to: 0.22, duration: 110, ease: 'out(3)' }, { to: 0.27, duration: 160, ease: 'linear' }, { to: 1, duration: 520, ease: 'outBack(1.8)' }],
          onUpdate: draw
        });
      }
      function mend() {
        if (!torn) return;
        torn = false;
        if (anim) anim.pause();
        anim = anime.animate(state, { p: 0, duration: 760, ease: 'inOut(3)', onUpdate: draw });
      }

      var lastReal = -1e9;
      el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { lastReal = performance.now(); tear(); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { lastReal = performance.now(); mend(); } });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType === 'mouse') return;
        lastReal = performance.now();
        if (torn) mend(); else tear();
      });

      if (d.autoplay === undefined || d.autoplay === 'false') return;
      var timer = null;
      function tick() {
        if (performance.now() - lastReal > interval) {
          tear();
          setTimeout(function () { if (performance.now() - lastReal > interval) mend(); }, interval * 0.45);
        }
        timer = setTimeout(tick, interval);
      }
      new IntersectionObserver(function (entries) {
        clearTimeout(timer);
        if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-torn-paper-text]').forEach(function (el) {
        if (el._tpt) return;
        el._tpt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-torn-paper-text data-paper="#fffaf0">Torn apart</h1>

<!-- just the letters, swinging wider: -->
<h1 data-torn-paper-text data-angle="7">Torn apart</h1>