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.
Grab the sticker near its edge and drag inwards
How to use it
- Paste the snippet just before
</head>. - Add
data-sticker-peelto 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-back | How light the backing is, from 0 (black) to 1 (white). Default 0.93. |
data-autoplay | Milliseconds: peel from a different corner on a timer until someone grabs it. |
data-amount | How 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.
<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><div data-sticker-peel style="width: 200px">
<img src="sticker.png" alt="Ship it sticker">
</div>