Page Peel Transition
The current view peels back from a corner like a sheet of paper: the crease sweeps across, the curled-over back of the sheet catches the light, and the next view is waiting underneath. As a page transition a sheet folds down over the page when a link is clicked, and on the next page it peels off again. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-page-peel-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-page-peel-transition="#hex" | On a page, the colour of the sheet that folds over it. Default #1e1b4b. |
data-corner | The corner it peels from: bottom-right, bottom-left, top-right or top-left. Default bottom-right. |
data-duration | Time for each half of a page change. Default 750 ms (1200 ms for a whole slideshow peel). |
data-interval | Contained mode only: time each slide stays. Default 2200 ms. |
Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Coming back with the Back button peels the sheet away again. Put data-page-peel-transition on any other element with two or more children and the real views peel away one after another, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Page Peel Transition: the current view peels back from a corner like a
// sheet of paper, its curling back sweeping across, to reveal the next view
// lying underneath.
// Usage: <body data-page-peel-transition> (add the snippet to every page)
// Optional: data-page-peel-transition="#hex" (on a page, the colour of the
// sheet that folds down over it and peels off the next page, default
// #1e1b4b), data-corner ("bottom-right", the default, "bottom-left",
// "top-right" or "top-left"), data-duration (ms: default 750 for each half
// of a page change, 1200 for a slideshow change).
// Contained mode: put data-page-peel-transition on any other element with
// two or more children. Every data-interval ms (default 2200) the current
// child peels away to show the next.
var DEFAULTS = { color: '#1e1b4b', corner: 'bottom-right', duration: 750, slideDuration: 1200, interval: 2200 };
var KEY = 'animacious-page-peel';
var css =
'html.ppt-pre body{opacity:0}' +
'.ppt-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
'.ppt-contained{position:relative;overflow:hidden;display:grid}' +
'.ppt-contained>*{grid-area:1/1}' +
'.ppt-contained>.ppt-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
'.ppt-page,.ppt-under,.ppt-flap{position:absolute;inset:0}' +
'.ppt-page>.ppt-copy{position:absolute;left:0;top:0;margin:0;max-width:none}' +
'.ppt-lift{position:absolute;inset:0;filter:drop-shadow(0 4px 10px rgba(0,0,0,.35))}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var arriving = false;
try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {}
function unhide() { document.documentElement.classList.remove('ppt-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('ppt-pre');
setTimeout(unhide, 3000);
}
function tween(duration, ease, paint, done) {
var s = { t: 0 };
paint(0);
return anime.animate(s, {
t: 1, duration: duration, ease: ease,
onUpdate: function () { paint(s.t); },
onComplete: function () { paint(1); if (done) done(); }
});
}
function linkFor(e) {
var a = e.target.closest && e.target.closest('a[href]');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
var url = new URL(a.href, location.href);
if (url.origin !== location.origin) return null;
if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
return url;
}
// Cut a polygon by a straight line, keeping the side where f(point) <= 0.
function cut(poly, f) {
var out = [];
for (var i = 0; i < poly.length; i++) {
var a = poly[i], b = poly[(i + 1) % poly.length], fa = f(a), fb = f(b);
if (fa <= 0) out.push(a);
if ((fa <= 0) !== (fb <= 0)) {
var k = fa / (fa - fb);
out.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]);
}
}
return out;
}
function shape(poly) {
if (poly.length < 3) return 'polygon(0 0,0 0,0 0)';
return 'polygon(' + poly.map(function (p) { return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'; }).join(',') + ')';
}
// A CSS gradient running along (dx, dy) across a w x h box, with stops
// given as distances from the line through point P that is square to it.
function along(w, h, dx, dy, P, stops) {
var deg = Math.atan2(dx, -dy) * 180 / Math.PI;
var len = Math.abs(w * dx) + Math.abs(h * dy);
var start = (P[0] - (w / 2 - dx * len / 2)) * dx + (P[1] - (h / 2 - dy * len / 2)) * dy;
return 'linear-gradient(' + deg.toFixed(2) + 'deg,' + stops.map(function (s) { return s[1] + ' ' + (start + s[0]).toFixed(1) + 'px'; }).join(',') + ')';
}
// The peel itself, over `fx` (w x h). `page` is the sheet that peels: a
// copy of the view, or a plain coloured sheet. Returns set(p): p = 0 lies
// flat, p = 1 is peeled right off.
function peeler(fx, page, w, h, corner, back) {
var right = corner.indexOf('left') < 0, bottom = corner.indexOf('top') < 0;
// Work as if peeling from the bottom-right, then mirror into place.
function place(p) { return [right ? p[0] : w - p[0], bottom ? p[1] : h - p[1]]; }
var ux = 1, uy = 0.62, ul = Math.hypot(ux, uy);
ux /= ul; uy /= ul;
var D = w * ux + h * uy;
var rect = [[0, 0], [w, 0], [w, h], [0, h]];
var under = document.createElement('div');
under.className = 'ppt-under';
var lift = document.createElement('div');
lift.className = 'ppt-lift';
var flap = document.createElement('div');
flap.className = 'ppt-flap';
lift.appendChild(flap);
page.classList.add('ppt-page');
fx.appendChild(under);
fx.appendChild(page);
fx.appendChild(lift);
var gx = right ? ux : -ux, gy = bottom ? uy : -uy; // the peel's direction on screen
return function set(p) {
var s = D * 1.02 * p;
var f = function (X) { return (X[0] - w) * ux + (X[1] - h) * uy + s; };
var kept = cut(rect, f);
var gone = cut(rect, function (X) { return -f(X); });
var flapPoly = gone.map(function (X) {
var k = 2 * f(X);
return [X[0] - k * ux, X[1] - k * uy];
});
var F = place([w - s * ux, h - s * uy]);
page.style.clipPath = shape(kept.map(place));
page.style.visibility = kept.length < 3 ? 'hidden' : '';
flap.style.clipPath = shape(flapPoly.map(place));
lift.style.visibility = flapPoly.length < 3 || s < 1 ? 'hidden' : '';
// The back of the sheet: shaded in the crease, catching the light
// where it curls over, a little darker towards its far edge.
var depth = Math.max(1, s);
flap.style.background = along(w, h, -gx, -gy, F, [
[0, 'color-mix(in srgb,' + back + ' 62%,#000)'],
[Math.min(depth * 0.12, 18), 'color-mix(in srgb,' + back + ' 86%,#000)'],
[depth * 0.45, back],
[depth, 'color-mix(in srgb,' + back + ' 88%,#000)']
]);
// A soft shadow on the view underneath, along the crease.
under.style.clipPath = shape(gone.map(place));
under.style.background = along(w, h, gx, gy, F, [
[0, 'rgba(0,0,0,.4)'],
[Math.min(40, depth * 0.35 + 6), 'rgba(0,0,0,0)']
]);
};
}
function cornerOf(node) {
var c = (node.dataset.corner || DEFAULTS.corner).toLowerCase();
return /^(top|bottom)-(left|right)$/.test(c) ? c : DEFAULTS.corner;
}
// ---------- Page mode: a sheet folds down over the page, and peels off the next ----------
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var color = body.getAttribute('data-page-peel-transition') || DEFAULTS.color;
var corner = cornerOf(body);
var fx = null, anim, trip = 0;
function run(out, done) {
if (anim) anim.pause();
if (fx) fx.remove();
fx = document.createElement('div');
fx.className = 'ppt-layer';
fx.setAttribute('aria-hidden', 'true');
body.appendChild(fx);
var sheet = document.createElement('div');
sheet.style.background = color;
var set = peeler(fx, sheet, innerWidth, innerHeight, corner, 'color-mix(in srgb,' + color + ' 55%,#fff)');
var mine = fx;
anim = tween(duration, out ? 'inOut(2)' : 'inOut(2)', function (t) { set(out ? 1 - t : t); }, function () {
if (!out) { mine.remove(); if (fx === mine) fx = null; }
if (done) done();
});
}
if (arriving) run(false);
unhide();
document.addEventListener('click', function (e) {
var url = linkFor(e);
if (!url) return;
e.preventDefault();
var t = ++trip, gone = false;
var go = function () { if (gone || t !== trip) return; gone = true; location.href = url.href; };
try { sessionStorage.setItem(KEY, '1'); } catch (err) {}
setTimeout(go, duration + 400);
try { run(true, go); } catch (err) { go(); }
});
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
try { sessionStorage.removeItem(KEY); } catch (err) {}
run(false);
});
}
// ---------- Contained mode: the real view peels away ----------
function containedMode(el, still) {
el.classList.add('ppt-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.slideDuration;
var corner = cornerOf(el);
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (still) return;
var timer = anime.createTimer({
duration: (+el.dataset.interval || DEFAULTS.interval) + duration,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
var fx = document.createElement('div');
fx.className = 'ppt-fx';
fx.setAttribute('aria-hidden', 'true');
el.appendChild(fx);
var page = document.createElement('div');
var copy = from.cloneNode(true);
copy.removeAttribute('id');
copy.querySelectorAll('[id]').forEach(function (x) { x.removeAttribute('id'); });
copy.classList.add('ppt-copy');
copy.style.width = w + 'px';
copy.style.height = h + 'px';
page.appendChild(copy);
var set = peeler(fx, page, w, h, corner, '#f4f1ea');
set(0);
to.style.visibility = 'visible';
from.style.visibility = 'hidden';
tween(duration, 'inOut(2)', set, function () {
fx.remove();
i = n;
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-page-peel-transition]').forEach(function (el) {
if (el._ppt) return;
el._ppt = true;
if (el === document.body) { if (!still) pageMode(el); }
else if (el.children.length > 1) containedMode(el, still);
});
unhide();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-page-peel-transition>
…
</body>