← Cards & images

Ticket Tear-off

A ticket with a perforated stub. Tap the stub or pull it away and it rips from the top down along the perforation, leaving ragged edges on both halves, then tumbles away. Loop it and a fresh stub fades back into place.

Animacious LiveMotion NightFri 14 Nov · 19:30 · Hall B
SeatA12

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ticket-tear-off to a ticket with two children: the ticket itself first, the stub last. Give each its own background, since they come apart.

Options

Add any of these attributes to the same element.

data-notchRadius of the notches at either end of the perforation. Default 10 px.
data-teethSize of the torn teeth. Default 6 px.
data-loopBring the stub back after it’s torn off.
data-holdHow long before it comes back. Default 1400 ms.
data-autoplayMilliseconds: tear and restore on a timer until someone interacts.

The notches are cut with a CSS mask, so for a shadow put a filter: drop-shadow(…) on the ticket rather than a box-shadow on its halves. The stub is a button: Enter or Space tears it too. From code, animacious.ticketTear(element) tears it and animacious.ticketTear(element, false) puts it back.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ticket Tear-off: a ticket with a perforated stub. Tear it (tap, click,
    // or pull the stub away) and it rips from the top down along the
    // perforation, leaving ragged edges, then drops away. With data-loop a
    // fresh stub fades back in place.
    // Usage:
    //   <div data-ticket-tear-off>
    //     <div>…the ticket…</div>
    //     <div>…the stub…</div>
    //   </div>
    // The first child is the ticket, the last the stub; style their backgrounds
    // yourself (they come apart, so each needs its own).
    // Optional: data-notch (radius of the notches either end of the
    // perforation, px, default 10), data-teeth (size of the torn teeth, px,
    // default 6), data-loop (bring the stub back after tearing), data-hold (ms
    // before it comes back, default 1400), data-autoplay (ms between tears,
    // until someone interacts).
    // Script use: animacious.ticketTear(el) tears it; animacious.ticketTear(el, false) puts it back.
    var DEFAULTS = { notch: 10, teeth: 6, hold: 1400 };

    var css =
      '.tkt{display:flex;align-items:stretch;--tkt-r:10px}' +
      '.tkt>.tkt-main{flex:1;min-width:0}' +
      '.tkt>.tkt-main,.tkt>.tkt-stub{-webkit-mask:var(--tkt-m);mask:var(--tkt-m)}' +
      '.tkt>.tkt-main{--tkt-m:radial-gradient(circle at 100% 0,#0000 var(--tkt-r),#000 calc(var(--tkt-r) + .5px)) top/100% 51% no-repeat,radial-gradient(circle at 100% 100%,#0000 var(--tkt-r),#000 calc(var(--tkt-r) + .5px)) bottom/100% 51% no-repeat}' +
      '.tkt>.tkt-stub{--tkt-m:radial-gradient(circle at 0 0,#0000 var(--tkt-r),#000 calc(var(--tkt-r) + .5px)) top/100% 51% no-repeat,radial-gradient(circle at 0 100%,#0000 var(--tkt-r),#000 calc(var(--tkt-r) + .5px)) bottom/100% 51% no-repeat;position:relative;transform-origin:0 100%;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}' +
      '.tkt>.tkt-stub::before{content:"";position:absolute;left:0;top:calc(var(--tkt-r) + 3px);bottom:calc(var(--tkt-r) + 3px);border-left:2px dashed currentColor;opacity:.35;pointer-events:none}' +
      '.tkt-torn>.tkt-stub::before{display:none}' +
      '.tkt>.tkt-stub:focus-visible{outline:2px solid currentColor;outline-offset:-6px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // A ragged edge: teeth along one side of the box, as a clip-path.
    function zigzag(h, t, side) {
      var n = Math.max(4, Math.round(h / (t * 1.6))), pts = [];
      for (var i = 0; i <= n; i++) {
        var y = (i / n * h).toFixed(1) + 'px';
        var jag = (i % 2 ? t : 0) + (i % 3 === 0 ? t * 0.3 : 0);
        pts.push(side === 'left' ? jag.toFixed(1) + 'px ' + y : 'calc(100% - ' + (t - jag + t * 0.3).toFixed(1) + 'px) ' + y);
      }
      if (side === 'left') return 'polygon(' + pts.join(',') + ',100% 100%,100% 0)';
      return 'polygon(0 0,' + pts.join(',') + ',0 100%)';
    }

    function setup(el) {
      var d = el.dataset;
      var kids = el.children;
      if (kids.length < 2) return;
      var main = kids[0], stub = kids[kids.length - 1];
      el.classList.add('tkt');
      el.style.setProperty('--tkt-r', (d.notch !== undefined ? +d.notch : DEFAULTS.notch) + 'px');
      main.classList.add('tkt-main');
      stub.classList.add('tkt-stub');
      var s = el._tktS = { main: main, stub: stub, torn: false, busy: false, timer: 0, anim: null, pull: { p: 0 } };

      var thumb = !!el.closest('[aria-hidden="true"]');
      if (!thumb) {
        stub.setAttribute('role', 'button');
        stub.tabIndex = 0;
        if (!stub.getAttribute('aria-label')) stub.setAttribute('aria-label', 'Tear off the stub');
        stub.addEventListener('keydown', function (e) {
          if (e.key !== 'Enter' && e.key !== ' ') return;
          e.preventDefault();
          stop(el);
          tear(el);
        });
      }

      // Pull the stub away (right or down) to start the tear; far enough and it rips.
      var start = null, moved = false;
      stub.addEventListener('pointerdown', function (e) {
        if (s.torn || s.busy || e.button > 0) return;
        stop(el);
        start = { x: e.clientX, y: e.clientY };
        moved = false;
        if (s.anim) s.anim.pause();
        stub.setPointerCapture(e.pointerId);
      });
      stub.addEventListener('pointermove', function (e) {
        if (!start) return;
        var pull = Math.max(0, (e.clientX - start.x) + (e.clientY - start.y) * 0.6);
        if (pull > 4) moved = true;
        s.pull.p = Math.min(1, pull / Math.max(60, stub.offsetWidth));
        pose(el);
        if (s.pull.p > 0.55) { start = null; tear(el); }
      });
      function up() {
        if (!start) return;
        start = null;
        if (!moved) { tear(el); return; }
        s.anim = anime.animate(s.pull, { p: 0, duration: 600, ease: 'outElastic(1, .45)', onUpdate: function () { pose(el); } });
      }
      stub.addEventListener('pointerup', up);
      stub.addEventListener('pointercancel', up);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var visible = false;
        new IntersectionObserver(function (en) { visible = en[0].isIntersecting; }).observe(el);
        s.timer = setInterval(function () { if (visible && !s.busy) { if (s.torn) restore(el); else tear(el); } }, +d.autoplay || 3000);
      }
    }

    // While pulling: the top of the stub comes away first.
    function pose(el) {
      var s = el._tktS;
      var p = s.pull.p;
      anime.utils.set(s.stub, { rotate: p * 10, x: p * 4 });
      s.stub.style.clipPath = p > 0.02 ? zigzag(s.stub.offsetHeight, +el.dataset.teeth || DEFAULTS.teeth, 'left') : '';
    }

    function stop(el) {
      var s = el._tktS;
      if (s && s.timer) { clearInterval(s.timer); s.timer = 0; }
    }

    function tear(el) {
      var s = el._tktS;
      if (!s || s.torn || s.busy) return;
      s.torn = true;
      s.busy = true;
      var d = el.dataset, t = +d.teeth || DEFAULTS.teeth, h = s.stub.offsetHeight;
      if (s.anim) s.anim.pause();
      el.classList.add('tkt-torn');
      s.stub.style.clipPath = zigzag(h, t, 'left');
      s.main.style.clipPath = zigzag(h, t, 'right');
      if (s.stub.getAttribute('role')) s.stub.setAttribute('aria-hidden', 'true');
      function done() {
        s.busy = false;
        s.stub.style.visibility = 'hidden';
        if (d.loop !== undefined && d.loop !== 'false') setTimeout(function () { restore(el); }, +d.hold || DEFAULTS.hold);
      }
      if (reduced()) { done(); return; }
      var from = s.pull.p;
      s.anim = anime.createTimeline({ onComplete: done })
        .add(s.stub, { rotate: [from * 10, 13], x: [from * 4, 6], duration: 260 * (1 - from) + 80, ease: 'out(2)' })
        .add(s.stub, { x: '+=' + Math.round(s.stub.offsetWidth * 0.35), y: h * 1.4, rotate: 38, opacity: 0, duration: 750, ease: 'in(2)' })
        .add(s.main, { x: [-5, 0], duration: 500, ease: 'outElastic(1, .4)' }, 220);
    }

    function restore(el) {
      var s = el._tktS;
      if (!s || !s.torn || s.busy) return;
      s.busy = true;
      s.pull.p = 0;
      s.stub.removeAttribute('aria-hidden');
      s.stub.style.visibility = '';
      s.main.style.clipPath = '';
      s.stub.style.clipPath = '';
      el.classList.remove('tkt-torn');
      anime.utils.set(s.stub, { x: 0, rotate: 0, y: -14, opacity: 0 });
      function done() { s.busy = false; s.torn = false; }
      if (reduced()) { anime.utils.set(s.stub, { y: 0, opacity: 1 }); done(); return; }
      s.anim = anime.animate(s.stub, { y: 0, opacity: 1, duration: 520, ease: 'out(3)', onComplete: done });
    }

    function init() {
      document.querySelectorAll('[data-ticket-tear-off]').forEach(function (el) {
        if (el._tkt) return;
        el._tkt = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.ticketTear = function (el, on) {
      stop(el);
      if (on === false) restore(el); else tear(el);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-ticket-tear-off data-loop style="filter: drop-shadow(0 6px 14px rgb(0 0 0 / .15))">
  <div class="ticket">Motion Night · Fri 19:30</div>
  <div class="stub">Seat A12</div>
</div>