Page Turn Book
An open book you leaf through by dragging a corner. The page folds along a crease that follows your finger, showing its back and the page underneath, with shading along the fold. Let go past the spine and it turns over; let go early and it falls back.
The Long Way Home
1. Morning
Mira left before the sun was up, with a flask of tea and a map that was mostly guesses. The lake was so still it held the whole mountain upside down.
1The lake at first light.
22. Dusk
By evening the path had turned to sand. Every dune was a different colour, and the sky could not decide between pink and gold.
3Sand that glows.
43. Night
Then the lights came, green and violet, rolling over the hills. She stopped walking. Home could wait a little longer.
5Worth the long way round.
6The End
Drag a page corner, tap a page or use the arrow keys
How to use it
- Paste the snippet just before
</head>. - Add
data-page-turn-bookto a box holding your pages, one child each, and give it a width. - The first page opens on the right, then they go left, right, left… The book is 3 : 2 unless you set an
aspect-ratio.
Options
Add any of these attributes to the same element.
data-duration | Time for a page to turn by itself, after a tap or a key press. Default 900 ms. |
data-autoplay | Milliseconds: turn a page on this timer, through the book and back, until someone interacts. |
data-peek="false" | Don’t lift the corner when a mouse hovers near it. |
data-shadow="false" | Leave out the shading along the fold. |
Pick up a page anywhere in its outer half; a quick flick turns it even if you haven’t reached the spine. A tap turns it too, and the arrow keys work once the book has focus. Upright scrolling still works on phones, as only sideways drags turn pages. From code: animacious.turnPage(element, 1) or -1. With reduced motion pages change without the fold.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Page Turn Book: an open book whose pages you turn by dragging a corner.
// The page folds along a crease that follows the pointer, showing its
// back and the page underneath, with shading along the fold. Let go past
// the spine and it turns over; let go early and it falls back. A tap or
// click on a page turns it too, the arrow keys work, and with a mouse the
// corner lifts a little when you hover near it.
// Usage: <div data-page-turn-book style="width: 560px">
// <div>Page 1</div> <div>Page 2</div> <div>Page 3</div> …
// </div>
// Each child is one page. The first opens on the right, then they go
// left, right, left… The book is 3 : 2 (two pages side by side) unless
// you set an aspect-ratio.
// Optional: data-duration (ms for a page to turn by itself, default 900),
// data-autoplay (ms: turn a page on this timer, back and forth through
// the book, until someone interacts), data-peek="false" (no corner lift
// on hover), data-shadow="false" (no shading).
// Script use: animacious.turnPage(el, 1 | -1).
var DEFAULTS = { duration: 900 };
var css =
':where(.ptb){aspect-ratio:3/2}' +
'.ptb{position:relative;touch-action:pan-y;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;outline-offset:6px}' +
'.ptb-leaf{position:absolute;top:0;left:0;width:50%;height:100%;visibility:hidden;transform-origin:0 0}' +
'.ptb-leaf.ptb-r{left:50%}' +
'.ptb-leaf.ptb-on{visibility:visible}' +
'.ptb-leaf.ptb-lift{filter:drop-shadow(0 0 10px rgba(0,0,0,.28))}' +
'.ptb-page{position:absolute;inset:0;overflow:hidden;background:#fffdf8}' +
'.ptb-page>:first-child{width:100%;height:100%;box-sizing:border-box}' +
'.ptb-page::after{content:"";position:absolute;top:0;bottom:0;width:12%;pointer-events:none}' +
'.ptb-r .ptb-page::after{left:0;background:linear-gradient(90deg,rgba(0,0,0,.13),rgba(0,0,0,0))}' +
'.ptb-l .ptb-page::after{right:0;background:linear-gradient(270deg,rgba(0,0,0,.13),rgba(0,0,0,0))}' +
'.ptb-shade{position:absolute;inset:0;z-index:1;pointer-events:none}';
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 kids = Array.prototype.slice.call(el.children);
if (!kids.length) return;
el.classList.add('ptb');
var thumb = !!el.closest('[aria-hidden="true"]');
var leaves = kids.map(function (child, i) {
var leaf = document.createElement('div');
leaf.className = 'ptb-leaf ' + (i % 2 ? 'ptb-l' : 'ptb-r');
var page = document.createElement('div');
page.className = 'ptb-page';
var shade = document.createElement('div');
shade.className = 'ptb-shade';
page.appendChild(child);
page.appendChild(shade);
leaf.appendChild(page);
el.appendChild(leaf);
return { leaf: leaf, page: page, shade: shade, right: i % 2 === 0 };
});
var s = el._ptbS = { leaves: leaves, n: kids.length, spread: 0, turn: null, raf: 0, last: 0, timer: 0, ahead: 1 };
show(el);
if (!thumb) {
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
if (!el.hasAttribute('role')) el.setAttribute('role', 'group');
el.setAttribute('aria-roledescription', 'book');
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Book: use the arrow keys to turn the pages');
el.addEventListener('keydown', function (e) {
var dir = e.key === 'ArrowRight' || e.key === 'PageDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'PageUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
stopAuto(el);
turnPage(el, dir);
});
}
var down = null;
el.addEventListener('pointerdown', function (e) {
if (e.button) return;
stopAuto(el);
var p = local(el, e);
if (s.turn && s.turn.mode === 'anim') return;
var dir = p.x >= s.W ? 1 : -1;
if (!canTurn(el, dir)) return;
// Only the outer part of a page can be picked up.
var fromEdge = dir > 0 ? 2 * s.W - p.x : p.x;
if (fromEdge > s.W * 0.6) return;
if (!s.turn || s.turn.dir !== dir) begin(el, dir, p.y < s.H / 2 ? 0 : s.H);
s.turn.mode = 'drag';
down = { x: p.x, y: p.y, t: performance.now(), id: e.pointerId };
s.turn.goal = clampM(s.turn, p, s);
try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
loop(el);
});
el.addEventListener('pointermove', function (e) {
var p = local(el, e);
if (down && s.turn && s.turn.mode === 'drag') { s.turn.goal = clampM(s.turn, p, s); loop(el); return; }
if (e.pointerType !== 'mouse' || d.peek === 'false' || reduced() || down) return;
peek(el, p);
});
function up(e) {
if (!down || !s.turn || s.turn.mode !== 'drag') { down = null; return; }
var p = local(el, e);
var t = s.turn, moved = Math.abs(p.x - down.x) + Math.abs(p.y - down.y);
var fast = (p.x - down.x) * -t.dir / Math.max(1, performance.now() - down.t) > 0.5;
var crossed = t.dir > 0 ? t.M.x < s.W : t.M.x > s.W;
down = null;
if (e.type === 'pointercancel') finish(el, false);
else finish(el, moved < 6 || crossed || fast);
}
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'mouse' && !down && s.turn && s.turn.mode === 'peek') settle(el);
});
new ResizeObserver(function () { measure(el); }).observe(el);
measure(el);
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var visible = false;
new IntersectionObserver(function (en) { visible = en[0].isIntersecting; }, { threshold: 0.3 }).observe(el);
s.timer = setInterval(function () {
if (!visible || s.turn) return;
if (!canTurn(el, s.ahead)) s.ahead = -s.ahead;
turnPage(el, s.ahead, s.ahead < 0 ? 0.6 : 1);
}, +d.autoplay || 2600);
}
}
function stopAuto(el) {
var s = el._ptbS;
if (s.timer) { clearInterval(s.timer); s.timer = 0; }
}
function measure(el) {
var s = el._ptbS;
s.W = el.clientWidth / 2;
s.H = el.clientHeight;
if (s.turn) apply(el);
}
function local(el, e) {
var r = el.getBoundingClientRect();
return { x: (e.clientX - r.left) * (el.clientWidth / (r.width || 1)), y: (e.clientY - r.top) * (el.clientHeight / (r.height || 1)) };
}
function canTurn(el, dir) {
var s = el._ptbS;
return dir > 0 ? 2 * s.spread < s.n : 2 * s.spread - 1 >= 0;
}
// Which pages are in view: the open spread, plus whatever a turn uncovers.
function show(el) {
var s = el._ptbS, t = s.turn;
s.leaves.forEach(function (L, i) {
var on = i === 2 * s.spread - 1 || i === 2 * s.spread;
if (t) on = on || i === t.back || i === t.under;
L.leaf.classList.toggle('ptb-on', on);
L.leaf.style.zIndex = t && i === t.back ? 3 : t && i === t.front ? 2 : 1;
L.leaf.classList.toggle('ptb-lift', !!t && i === t.back && el.dataset.shadow !== 'false');
if (!t || (i !== t.front && i !== t.back)) {
L.leaf.style.transform = '';
L.page.style.clipPath = '';
L.shade.style.background = '';
}
});
}
function begin(el, dir, cy) {
var s = el._ptbS, sp = 2 * s.spread;
var C0 = { x: dir > 0 ? 2 * s.W : 0, y: cy };
s.turn = {
dir: dir, mode: 'peek', C0: C0, M: { x: C0.x, y: C0.y }, goal: { x: C0.x, y: C0.y },
front: dir > 0 ? sp : sp - 1,
back: dir > 0 ? sp + 1 : sp - 2,
under: dir > 0 ? sp + 2 : sp - 3
};
show(el);
}
function peek(el, p) {
var s = el._ptbS;
var dir = p.x >= s.W ? 1 : -1;
var cx = dir > 0 ? 2 * s.W : 0, cy = p.y < s.H / 2 ? 0 : s.H;
var near = Math.abs(p.x - cx) < s.W * 0.28 && Math.abs(p.y - cy) < s.H * 0.28 && canTurn(el, dir);
if (s.turn && s.turn.mode !== 'peek') return;
if (!near) { if (s.turn) settle(el); return; }
if (!s.turn || s.turn.dir !== dir || s.turn.C0.y !== cy) begin(el, dir, cy);
// Lift the corner a little towards the pointer.
var lift = Math.min(s.W, s.H) * 0.16;
s.turn.goal = clampM(s.turn, { x: cx - dir * lift + (p.x - cx) * 0.3, y: cy + (cy ? -lift : lift) + (p.y - cy) * 0.3 }, s);
loop(el);
}
function settle(el) {
var s = el._ptbS;
if (!s.turn) return;
s.turn.mode = 'settle';
s.turn.goal = { x: s.turn.C0.x, y: s.turn.C0.y };
loop(el);
}
// The corner follows its goal with a little lag, like paper.
function loop(el) {
var s = el._ptbS;
if (s.raf) return;
s.last = performance.now();
s.raf = requestAnimationFrame(function tick(now) {
s.raf = 0;
var t = s.turn;
if (!t || t.mode === 'anim') return;
var k = Math.min(1, (now - s.last) / 70);
s.last = now;
t.M.x += (t.goal.x - t.M.x) * k;
t.M.y += (t.goal.y - t.M.y) * k;
var dist = Math.abs(t.goal.x - t.M.x) + Math.abs(t.goal.y - t.M.y);
if (t.mode === 'settle' && dist < 0.5) { s.turn = null; show(el); return; }
apply(el);
if (dist > 0.3 || t.mode === 'drag') s.raf = requestAnimationFrame(tick);
});
}
// Keep the corner where paper fixed at the spine could reach.
function clampM(t, p, s) {
var m = { x: p.x, y: p.y };
var near = { x: s.W, y: t.C0.y }, far = { x: s.W, y: s.H - t.C0.y };
var lim = [[near, s.W], [far, Math.sqrt(s.W * s.W + s.H * s.H)]];
lim.forEach(function (l) {
var dx = m.x - l[0].x, dy = m.y - l[0].y, dd = Math.sqrt(dx * dx + dy * dy);
if (dd > l[1]) { m.x = l[0].x + dx * l[1] / dd; m.y = l[0].y + dy * l[1] / dd; }
});
return m;
}
// Cut a rectangle by the line through P with normal n, keeping the side sign * (q - P)·n >= 0.
function cut(x0, x1, H, P, n, sign) {
var pts = [[x0, 0], [x1, 0], [x1, H], [x0, H]], out = [];
function f(q) { return sign * ((q[0] - P.x) * n.x + (q[1] - P.y) * n.y); }
for (var i = 0; i < 4; i++) {
var a = pts[i], b = pts[(i + 1) % 4], 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 poly(pts, dx) {
if (pts.length < 3) return 'polygon(0 0,0 0,0 0)';
return 'polygon(' + pts.map(function (q) { return (q[0] - dx).toFixed(2) + 'px ' + q[1].toFixed(2) + 'px'; }).join(',') + ')';
}
// A shadow band along the fold, fading into the region it points at.
function band(w, h, P, g, stops) {
var ang = Math.atan2(g.x, -g.y);
var L = Math.abs(w * Math.sin(ang)) + Math.abs(h * Math.cos(ang));
var t0 = ((P.x - w / 2) * g.x + (P.y - h / 2) * g.y) / L;
return 'linear-gradient(' + (ang * 180 / Math.PI).toFixed(2) + 'deg,' + stops.map(function (st) {
return st[1] + ' ' + ((t0 + 0.5 + st[0] / L) * 100).toFixed(2) + '%';
}).join(',') + ')';
}
function apply(el) {
var s = el._ptbS, t = s.turn;
if (!t) return;
var W = s.W, H = s.H, F = s.leaves[t.front], B = s.leaves[t.back];
var vx = t.M.x - t.C0.x, vy = t.M.y - t.C0.y, len = Math.sqrt(vx * vx + vy * vy);
var shadow = el.dataset.shadow !== 'false';
if (len < 0.5) {
F.page.style.clipPath = '';
F.shade.style.background = '';
if (B) B.leaf.style.visibility = 'hidden';
return;
}
if (B) B.leaf.style.visibility = '';
var n = { x: vx / len, y: vy / len }, P = { x: (t.C0.x + t.M.x) / 2, y: (t.C0.y + t.M.y) / 2 };
var fx0 = t.dir > 0 ? W : 0;
// Front: keep the part on the spine side of the fold.
F.page.style.clipPath = poly(cut(fx0, fx0 + W, H, P, n, 1), fx0);
var depth = Math.min(1, len / W);
if (shadow) F.shade.style.background = band(W, H, { x: P.x - fx0, y: P.y }, n, [[0, 'rgba(0,0,0,' + (0.22 * depth).toFixed(3) + ')'], [W * 0.35, 'rgba(0,0,0,0)']]);
if (!B) return;
// Back: the folded-over flap. Mirror across the spine, then across the fold.
var bx0 = t.dir > 0 ? 0 : W;
var flap = cut(fx0, fx0 + W, H, P, n, -1).map(function (q) { return [2 * W - q[0], q[1]]; });
B.page.style.clipPath = poly(flap, bx0);
var r11 = 1 - 2 * n.x * n.x, r12 = -2 * n.x * n.y, r22 = 1 - 2 * n.y * n.y;
var a = -r11, b = -r12, c = r12, d = r22; // (reflect across fold) x (flip across spine)
var pd = P.x * n.x + P.y * n.y;
var tx = r11 * 2 * W + 2 * pd * n.x, ty = r12 * 2 * W + 2 * pd * n.y; // where the spine-flip sends the origin
var e = a * bx0 + tx - bx0, f = b * bx0 + ty;
B.leaf.style.transform = 'matrix(' + [a, b, c, d, e, f].map(function (v) { return v.toFixed(5); }).join(',') + ')';
if (shadow) {
var Pb = { x: 2 * W - P.x - bx0, y: P.y };
B.shade.style.background = band(W, H, Pb, { x: n.x, y: -n.y }, [
[0, 'rgba(0,0,0,' + (0.2 * depth).toFixed(3) + ')'],
[W * 0.08, 'rgba(255,255,255,' + (0.18 * depth).toFixed(3) + ')'],
[W * 0.4, 'rgba(0,0,0,0)']
]);
}
}
// Animate the corner the rest of the way over (done) or back home.
function finish(el, done) {
var s = el._ptbS, t = s.turn;
if (!t) return;
t.mode = 'anim';
var from = { x: t.M.x, y: t.M.y };
var to = done ? { x: 2 * s.W - t.C0.x, y: t.C0.y } : { x: t.C0.x, y: t.C0.y };
var lift = done ? s.H * 0.06 * (t.C0.y ? -1 : 1) : 0;
var full = +el.dataset.duration || DEFAULTS.duration;
var share = Math.abs(to.x - from.x) / (2 * s.W);
var v = { p: 0 };
var end = function () {
if (done) s.spread += t.dir;
s.turn = null;
show(el);
};
if (reduced()) { end(); return; }
anime.animate(v, {
p: 1,
duration: Math.max(220, full * (done ? Math.max(0.45, share) : share * 1.4) * (t.speed || 1)),
ease: done ? 'inOut(2)' : 'out(3)',
onUpdate: function () {
t.M.x = from.x + (to.x - from.x) * v.p;
t.M.y = from.y + (to.y - from.y) * v.p + lift * Math.sin(Math.PI * v.p);
t.M = clampM(t, t.M, s);
apply(el);
},
onComplete: end
});
}
function turnPage(el, dir, speed) {
var s = el._ptbS;
if (!s || !canTurn(el, dir) || (s.turn && s.turn.mode === 'anim')) return;
if (!s.turn || s.turn.dir !== dir) begin(el, dir, s.H);
s.turn.speed = speed || 1;
finish(el, true);
}
function init() {
document.querySelectorAll('[data-page-turn-book]').forEach(function (el) {
if (el._ptb) return;
el._ptb = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.turnPage = function (el, dir) { stopAuto(el); turnPage(el, dir < 0 ? -1 : 1); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-page-turn-book style="width: 560px">
<div class="page">Title page</div>
<div class="page">Page 1</div>
<div class="page">Page 2</div>
<div class="page">Page 3</div>
</div>