← back to Dw Marketing Reels
public/collections/graduate-flipbook/page-flip.js
313 lines
/*!
* page-flip.js — a zero-dependency drag-to-paginate book / sketchbook UI.
*
* Seeded by Meng To (@MengTo)'s 2026-08-06 "page-flipping sketchbook" post:
* "AI gives you the basics unless you hand it a solid open-source to work
* from. So I asked for drag to paginate, which took many fixes for shadows."
* This file IS that solid open-source to work from. The hard parts it gets
* right — the ones "the basics" skip — are called out inline: the curl
* ambient-occlusion shadow, the gutter/spine shadow, velocity-flick release,
* and keeping the shadow synced to the page THROUGH the snap animation.
*
* Contract (see demo.html):
* <div class="pageflip" data-pageflip>
* <div class="pf-page">…page 1…</div>
* <div class="pf-page">…page 2…</div>
* …
* </div>
* Each direct .pf-page child becomes one leaf. Author content lives on the
* front; the back is an auto-generated paper backside.
*
* Usage:
* const book = new PageFlip(el, { onFlip(i){…} }); // or auto-init below
* book.next(); book.prev(); book.goTo(3);
*
* No build step, no framework, no license friction — vanilla ES2019.
*/
(function (global) {
'use strict';
var DEFAULTS = {
dragZone: 0.5, // fraction of page width (from the outer edge) that starts a drag
flipThreshold: 0.5, // release past this fraction of the turn (0..1) completes the flip
flickVelocity: 0.6, // px/ms; a faster release completes the flip regardless of threshold
snapMs: 420, // snap animation duration
curlShadow: 0.42, // peak opacity of the curl ambient-occlusion shadow (0..1)
gutterShadow: 0.28, // peak opacity of the fixed spine/gutter shadow (0..1)
perspective: 2200, // px; larger = flatter/less dramatic depth
onFlip: null, // (index) => void, fired after a completed flip settles
onTurn: null // (progress, dir) => void, fired continuously while turning (progress 0..1)
};
// power-in-out easing keeps the snap feeling weighted, not linear/robotic.
function easeInOut(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function PageFlip(root, opts) {
if (!root) throw new Error('PageFlip: root element required');
if (root.__pageflip) return root.__pageflip; // idempotent
this.root = root;
this.o = Object.assign({}, DEFAULTS, opts || {});
this.pages = [];
this.index = 0; // top face-up leaf
this.dragging = false;
this.raf = 0;
root.__pageflip = this;
this._build();
this._bind();
this._layout();
}
PageFlip.prototype._build = function () {
var root = this.root;
root.classList.add('pageflip');
root.style.setProperty('--pf-persp', this.o.perspective + 'px');
// Promote each .pf-page child into a two-faced leaf with a curl-shadow layer.
var kids = Array.prototype.filter.call(root.children, function (c) {
return c.classList && c.classList.contains('pf-page');
});
var self = this;
kids.forEach(function (leaf, i) {
// Move author content into a front face (once — re-init safe).
if (!leaf.querySelector(':scope > .pf-front')) {
var front = document.createElement('div');
front.className = 'pf-face pf-front';
while (leaf.firstChild) front.appendChild(leaf.firstChild);
leaf.appendChild(front);
var back = document.createElement('div');
back.className = 'pf-face pf-back';
back.setAttribute('aria-hidden', 'true');
leaf.appendChild(back);
// Curl shadow: a gradient overlay whose opacity we drive from the turn
// angle. This is the "many fixes for shadows" layer — see physics.md.
var curl = document.createElement('div');
curl.className = 'pf-curl';
curl.setAttribute('aria-hidden', 'true');
leaf.appendChild(curl);
}
leaf.dataset.pfIndex = String(i);
leaf.setAttribute('role', 'group');
leaf.setAttribute('aria-roledescription', 'page');
self.pages.push(leaf);
});
// Fixed gutter/spine shadow — always present at the binding edge so the
// book reads as bound even when no page is turning.
if (!root.querySelector(':scope > .pf-gutter')) {
var gutter = document.createElement('div');
gutter.className = 'pf-gutter';
gutter.setAttribute('aria-hidden', 'true');
root.appendChild(gutter);
root.style.setProperty('--pf-gutter', this.o.gutterShadow);
}
};
PageFlip.prototype._layout = function () {
// Resting state: leaves before `index` are turned (-180°) and stack on the
// left ascending; leaves at/after `index` are flat and stack on the right
// with `index` on top.
var n = this.pages.length;
for (var i = 0; i < n; i++) {
var leaf = this.pages[i];
if (i < this.index) {
this._setLeaf(leaf, -180, 0);
leaf.style.zIndex = String(i); // earlier turned pages sink lower
leaf.classList.add('pf-turned');
} else {
this._setLeaf(leaf, 0, 0);
leaf.style.zIndex = String(n - i); // current on top, rest descend
leaf.classList.remove('pf-turned');
}
leaf.setAttribute('aria-hidden', i === this.index || i === this.index + 1 ? 'false' : 'true');
}
this.root.dataset.pfIndex = String(this.index);
};
// Apply a rotation + its matching shadow to one leaf. angleDeg in [-180, 0].
PageFlip.prototype._setLeaf = function (leaf, angleDeg, forceShadow) {
leaf.style.transform = 'rotateY(' + angleDeg + 'deg)';
var curl = leaf.querySelector(':scope > .pf-curl');
if (!curl) return;
// sin() peaks at 90° (page standing up) and vanishes flat at 0°/180° — the
// physically-right shape for ambient occlusion of a curling sheet.
var a = Math.abs(angleDeg);
var s = (forceShadow != null ? forceShadow : Math.sin(a * Math.PI / 180)) * this.o.curlShadow;
curl.style.opacity = String(s);
// Past 90° we're looking at the back of the sheet: flip the gradient so the
// shading gathers toward the (now leading) spine edge, not the free edge.
curl.style.setProperty('--pf-curl-dir', a > 90 ? '1' : '0');
};
PageFlip.prototype._bind = function () {
var self = this;
this._onDown = function (e) { self._down(e); };
this._onMove = function (e) { self._move(e); };
this._onUp = function (e) { self._up(e); };
this.root.addEventListener('pointerdown', this._onDown);
// Keyboard paging for accessibility. Only set tabIndex when the element
// has no explicit tabindex attribute; avoids overwriting intentional -1.
if (!this.root.hasAttribute('tabindex')) this.root.tabIndex = 0;
// Stored (not anonymous) so destroy() can remove it — otherwise re-init on
// the same element stacks keydown listeners.
this._onKey = function (e) {
if (e.key === 'ArrowRight' || e.key === 'PageDown') { self.next(); e.preventDefault(); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { self.prev(); e.preventDefault(); }
};
this.root.addEventListener('keydown', this._onKey);
this._onResize = function () { self.width = self.root.clientWidth; };
window.addEventListener('resize', this._onResize);
this.width = this.root.clientWidth;
};
PageFlip.prototype._down = function (e) {
if (this.animating) return;
var rect = this.root.getBoundingClientRect();
var x = (e.clientX - rect.left) / rect.width; // 0 (spine/left) .. 1 (outer/right)
var dir;
// Right dragZone turns forward (needs a next page); left turns back.
if (x >= 1 - this.o.dragZone && this.index < this.pages.length - 1) dir = 1;
else if (x <= this.o.dragZone && this.index > 0) dir = -1;
else return;
this.dragging = true;
this.dir = dir;
this.startX = e.clientX;
this.width = rect.width;
this.lastX = e.clientX;
this.lastT = e.timeStamp;
this.vel = 0;
// The leaf that visually turns: the current leaf when going forward, the
// previous (already-turned) leaf when going back.
this.active = dir === 1 ? this.pages[this.index] : this.pages[this.index - 1];
this.active.style.zIndex = String(this.pages.length + 1); // ride on top while turning
this.active.classList.add('pf-turning');
this.root.classList.add('pf-grabbing');
try { this.root.setPointerCapture(e.pointerId); } catch (_) {}
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
window.addEventListener('pointercancel', this._onUp);
e.preventDefault();
};
PageFlip.prototype._move = function (e) {
if (!this.dragging) return;
var dt = e.timeStamp - this.lastT;
if (dt > 0) this.vel = (e.clientX - this.lastX) / dt; // px/ms, signed
this.lastX = e.clientX; this.lastT = e.timeStamp;
var dx = e.clientX - this.startX;
// progress 0 (flat/resting) .. 1 (fully turned) for this drag direction.
var progress = this.dir === 1
? clamp(-dx / this.width, 0, 1) // forward: drag left (dx<0)
: clamp(dx / this.width, 0, 1); // back: drag right (dx>0)
// Forward turns the current leaf 0 -> -180; back turns the prev leaf -180 -> 0.
var angle = this.dir === 1 ? -progress * 180 : -180 + progress * 180;
this._setLeaf(this.active, angle);
this.progress = progress;
if (this.o.onTurn) this.o.onTurn(progress, this.dir);
};
PageFlip.prototype._up = function () {
if (!this.dragging) return;
this.dragging = false;
this.root.classList.remove('pf-grabbing');
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
window.removeEventListener('pointercancel', this._onUp);
// A quick flick completes the flip even if you didn't cross the threshold —
// velocity sign must agree with the turn direction (drag left = negative).
var flick = (this.dir === 1 && this.vel < -this.o.flickVelocity) ||
(this.dir === -1 && this.vel > this.o.flickVelocity);
var complete = this.progress >= this.o.flipThreshold || flick;
this._settle(complete);
};
// Animate the active leaf to its resting angle, driving the shadow the whole
// way so it never "pops" at the seam between drag and snap.
PageFlip.prototype._settle = function (complete) {
var self = this;
this.animating = true;
var fromProg = this.progress || 0;
var toProg = complete ? 1 : 0;
var t0 = null;
cancelAnimationFrame(this.raf);
function frame(t) {
if (t0 == null) t0 = t;
var k = clamp((t - t0) / self.o.snapMs, 0, 1);
var p = fromProg + (toProg - fromProg) * easeInOut(k);
var angle = self.dir === 1 ? -p * 180 : -180 + p * 180;
self._setLeaf(self.active, angle);
if (self.o.onTurn) self.o.onTurn(p, self.dir);
if (k < 1) { self.raf = requestAnimationFrame(frame); return; }
// Landed. Commit the index and re-layout to the clean resting state.
self.active.classList.remove('pf-turning');
if (complete) self.index += self.dir;
self.animating = false;
self._layout();
if (complete && self.o.onFlip) self.o.onFlip(self.index);
}
this.raf = requestAnimationFrame(frame);
};
// Programmatic paging animates via the same settle path for a consistent feel.
PageFlip.prototype._go = function (dir) {
if (this.animating || this.dragging) return false;
if (dir === 1 && this.index >= this.pages.length - 1) return false;
if (dir === -1 && this.index <= 0) return false;
this.dir = dir;
this.width = this.root.clientWidth;
this.progress = 0;
this.active = dir === 1 ? this.pages[this.index] : this.pages[this.index - 1];
this.active.style.zIndex = String(this.pages.length + 1);
this.active.classList.add('pf-turning');
this._settle(true);
return true;
};
PageFlip.prototype.next = function () { return this._go(1); };
PageFlip.prototype.prev = function () { return this._go(-1); };
PageFlip.prototype.goTo = function (i) {
i = clamp(i | 0, 0, this.pages.length - 1);
if (this.animating || this.dragging) return;
this.index = i; this._layout();
if (this.o.onFlip) this.o.onFlip(this.index);
};
PageFlip.prototype.count = function () { return this.pages.length; };
PageFlip.prototype.current = function () { return this.index; };
PageFlip.prototype.destroy = function () {
cancelAnimationFrame(this.raf);
this.root.removeEventListener('pointerdown', this._onDown);
this.root.removeEventListener('keydown', this._onKey);
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
window.removeEventListener('pointercancel', this._onUp);
window.removeEventListener('resize', this._onResize);
delete this.root.__pageflip;
};
// Auto-init any [data-pageflip] on DOM ready so the drop-in "just works".
function auto() {
var els = document.querySelectorAll('[data-pageflip]');
Array.prototype.forEach.call(els, function (el) {
if (!el.__pageflip) new PageFlip(el);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', auto);
} else {
auto();
}
global.PageFlip = PageFlip;
if (typeof module !== 'undefined' && module.exports) module.exports = PageFlip;
})(typeof window !== 'undefined' ? window : this);