← Cards & images

Sticker Peel

A die-cut sticker you can peel. Grab it near the edge and drag inwards: the lifted part folds back along a crease that follows your finger, showing the pale backing and a soft shadow. Let go and it slaps back down flat.

SHIP IT

Grab the sticker near its edge and drag inwards

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sticker-peel to an element holding the sticker, and give it a size. An SVG or a PNG with a transparent background peels along its own outline.

Options

Add any of these attributes to the same element.

data-backHow light the backing is, from 0 (black) to 1 (white). Default 0.93.
data-autoplayMilliseconds: peel from a different corner on a timer until someone grabs it.
data-amountHow far the pretend peel goes, from 0 to 1. Default 0.6.

The backing is a copy of the sticker turned into a flat silhouette and mirrored across the crease, so any shape works. It responds to mouse, pen and touch, and doesn’t scroll the page while you peel.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sticker Peel: grab a die-cut sticker near its edge and drag inwards to
    // peel it off the page. The lifted part folds back along a crease that
    // follows your pointer, showing the sticker's pale backing and casting a
    // shadow; let go and it springs flat again.
    // Usage: <div data-sticker-peel><svg>…</svg></div>
    // Any element works; shapes with transparent edges (SVG, PNG) peel along
    // their own outline. Give the sticker a size.
    // Optional: data-back (how light the backing is, 0 to 1, default 0.93),
    // data-autoplay (ms between pretend peels, until someone grabs it),
    // data-amount (how far the pretend peel goes, 0 to 1, default 0.6).
    var DEFAULTS = { back: 0.93, amount: 0.6 };

    var css =
      '.skp{position:relative;touch-action:none;cursor:grab;-webkit-user-select:none;user-select:none}' +
      '.skp.skp-drag{cursor:grabbing}' +
      '.skp-front{display:block;width:100%;height:100%}' +
      '.skp-front>*,.skp-backin>*{display:block;width:100%;height:100%;pointer-events:none}' +
      '.skp-back{position:absolute;left:0;top:0;overflow:hidden;transform-origin:0 0;pointer-events:none;filter:drop-shadow(0 6px 8px rgba(15,15,30,.3)) drop-shadow(0 1px 1px rgba(15,15,30,.25));display:none}' +
      '.skp-backin{position:absolute}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function stripIds(node) {
      if (node.removeAttribute) {
        node.removeAttribute('id');
        node.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      }
      return node;
    }

    // Keep the part of a slightly padded box on one side of the line n·p = c.
    function halfPlane(w, h, nx, ny, c, side) {
      var m = 2;
      var pts = [[-m, -m], [w + m, -m], [w + m, h + m], [-m, h + m]], out = [];
      function f(p) { return side * (nx * p[0] + ny * p[1] - c); }
      for (var i = 0; i < pts.length; i++) {
        var a = pts[i], b = pts[(i + 1) % pts.length], fa = f(a), fb = f(b);
        if (fa >= 0) out.push(a);
        if ((fa >= 0) !== (fb >= 0)) {
          var t = fa / (fa - fb);
          out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
        }
      }
      return out;
    }

    function poly(pts, ox, oy) {
      if (!pts.length) return 'polygon(0 0,0 0,0 0)';
      return 'polygon(' + pts.map(function (p) { return (p[0] - ox).toFixed(1) + 'px ' + (p[1] - oy).toFixed(1) + 'px'; }).join(',') + ')';
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('skp');
      var front = document.createElement('div');
      front.className = 'skp-front';
      while (el.firstChild) front.appendChild(el.firstChild);
      el.appendChild(front);

      var back = document.createElement('div');
      back.className = 'skp-back';
      back.setAttribute('aria-hidden', 'true');
      var backin = document.createElement('div');
      backin.className = 'skp-backin';
      Array.prototype.forEach.call(front.children, function (ch) { backin.appendChild(stripIds(ch.cloneNode(true))); });
      // The backing: the sticker's own outline turned into flat pale paper.
      var shade = el.dataset.back !== undefined ? +el.dataset.back : DEFAULTS.back;
      backin.style.filter = 'brightness(0) invert(' + shade + ')';
      back.appendChild(backin);
      el.appendChild(back);
      var s = el._skpS = { front: front, back: back, backin: backin, p0: null, tip: { x: 0, y: 0 }, anim: null, timer: 0, corner: 0 };

      var grab = null;
      el.addEventListener('pointerdown', function (e) {
        if (e.button > 0) return;
        stop(el);
        if (s.anim) s.anim.pause();
        var p = local(el, e);
        var w = el.offsetWidth, h = el.offsetHeight;
        // Start the peel at the sticker's edge, in line with where it was grabbed.
        var cx = w / 2, cy = h / 2, vx = p.x - cx, vy = p.y - cy;
        var k = Math.min(Math.abs(vx) > 0.01 ? (w / 2) / Math.abs(vx) : 1e9, Math.abs(vy) > 0.01 ? (h / 2) / Math.abs(vy) : 1e9) * 0.88;
        if (!isFinite(k) || k > 1e8) { vx = 0; vy = h / 2; k = 0.88; }
        s.p0 = { x: cx + vx * k, y: cy + vy * k };
        grab = { x: p.x, y: p.y };
        s.tip.x = s.p0.x; s.tip.y = s.p0.y;
        el.classList.add('skp-drag');
        el.setPointerCapture(e.pointerId);
      });
      el.addEventListener('pointermove', function (e) {
        if (!grab) return;
        var p = local(el, e);
        s.tip.x = s.p0.x + (p.x - grab.x);
        s.tip.y = s.p0.y + (p.y - grab.y);
        draw(el);
      });
      function release() {
        if (!grab) return;
        grab = null;
        el.classList.remove('skp-drag');
        springBack(el);
      }
      el.addEventListener('pointerup', release);
      el.addEventListener('pointercancel', release);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        s.visible = false;
        new IntersectionObserver(function (en) { s.visible = en[0].isIntersecting; }).observe(el);
        s.timer = setInterval(function () { if (s.visible && !grab) pretend(el); }, +d.autoplay || 3200);
        setTimeout(function () { if (s.visible && s.timer) pretend(el); }, 700);
      }
    }

    function local(el, e) {
      var r = el.getBoundingClientRect();
      return { x: (e.clientX - r.left) * el.offsetWidth / r.width, y: (e.clientY - r.top) * el.offsetHeight / r.height };
    }

    function draw(el) {
      var s = el._skpS, p0 = s.p0, w = el.offsetWidth, h = el.offsetHeight;
      var dx = s.tip.x - p0.x, dy = s.tip.y - p0.y, len = Math.sqrt(dx * dx + dy * dy);
      var max = 1.6 * Math.max(w, h);
      // Only peel inwards, and only so far.
      if (len < 0.5 || dx * (w / 2 - p0.x) + dy * (h / 2 - p0.y) < 0) { flat(s); return; }
      if (len > max) { dx *= max / len; dy *= max / len; len = max; }
      var nx = dx / len, ny = dy / len;
      var c = nx * (p0.x + dx / 2) + ny * (p0.y + dy / 2);
      s.front.style.clipPath = poly(halfPlane(w, h, nx, ny, c, 1), 0, 0);
      // The flipped part: its layer is only as big as the part itself, with the
      // full-size silhouette inside, offset and clipped to that part.
      var part = halfPlane(w, h, nx, ny, c, -1);
      var bx = Infinity, by = Infinity, bx2 = -Infinity, by2 = -Infinity;
      part.forEach(function (p) { bx = Math.min(bx, p[0]); by = Math.min(by, p[1]); bx2 = Math.max(bx2, p[0]); by2 = Math.max(by2, p[1]); });
      if (!part.length) { bx = by = 0; bx2 = by2 = 1; }
      var bs = s.backin.style;
      bs.left = -bx + 'px'; bs.top = -by + 'px'; bs.width = w + 'px'; bs.height = h + 'px';
      s.backin.style.clipPath = poly(part, 0, 0);
      s.back.style.width = (bx2 - bx).toFixed(1) + 'px';
      s.back.style.height = (by2 - by).toFixed(1) + 'px';
      s.back.style.left = bx + 'px';
      s.back.style.top = by + 'px';
      // Reflect across the crease: p' = A p + t, rebased on the layer's corner.
      var a = 1 - 2 * nx * nx, b = -2 * nx * ny, dd = 1 - 2 * ny * ny;
      var tx = 2 * c * nx + a * bx + b * by - bx, ty = 2 * c * ny + b * bx + dd * by - by;
      s.back.style.transform = 'matrix(' + [a, b, b, dd, tx, ty].map(function (v) { return v.toFixed(4); }).join(',') + ')';
      s.back.style.display = 'block';
    }

    function flat(s) {
      s.front.style.clipPath = '';
      s.back.style.display = 'none';
    }

    function springBack(el) {
      var s = el._skpS;
      if (s.anim) s.anim.pause();
      var from = { x: s.tip.x, y: s.tip.y };
      var dist = Math.hypot(from.x - s.p0.x, from.y - s.p0.y);
      s.anim = anime.animate(s.tip, {
        x: s.p0.x, y: s.p0.y,
        duration: reduced() ? 0 : Math.min(650, 260 + dist * 1.6),
        ease: 'in(1.6)',
        onUpdate: function () { draw(el); },
        onComplete: function () {
          flat(s);
          // A little slap as it lands back on the page.
          if (!reduced() && dist > 8) anime.animate(s.front, { scale: [1.04, 1], duration: 500, ease: 'outElastic(1, .5)' });
        }
      });
    }

    // Autoplay: peel from one corner towards the opposite one, then let go.
    function pretend(el) {
      var s = el._skpS, w = el.offsetWidth, h = el.offsetHeight;
      var corners = [[1, 1], [0, 1], [1, 0], [0, 0]];
      var cr = corners[s.corner++ % corners.length];
      var inset = 0.14;
      s.p0 = { x: (cr[0] ? 1 - inset : inset) * w, y: (cr[1] ? 1 - inset : inset) * h };
      var amount = +el.dataset.amount || DEFAULTS.amount;
      var to = { x: s.p0.x + (w / 2 - s.p0.x) * 2 * amount, y: s.p0.y + (h / 2 - s.p0.y) * 2 * amount - h * 0.08 };
      s.tip.x = s.p0.x; s.tip.y = s.p0.y;
      if (s.anim) s.anim.pause();
      s.anim = anime.createTimeline()
        .add(s.tip, { x: to.x, y: to.y, duration: 1100, ease: 'inOut(2)', onUpdate: function () { draw(el); } })
        .add(s.tip, { x: to.x + w * 0.04, y: to.y - h * 0.03, duration: 450, ease: 'inOut(2)', onUpdate: function () { draw(el); } })
        .call(function () { springBack(el); }, '+=150');
    }

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

    function init() {
      document.querySelectorAll('[data-sticker-peel]').forEach(function (el) {
        if (el._skp) return;
        el._skp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sticker-peel style="width: 200px">
  <img src="sticker.png" alt="Ship it sticker">
</div>