← Cards & images

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.

A story in pictures

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.

1

The lake at first light.

2
2. 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.

3

Sand that glows.

4
3. Night

Then the lights came, green and violet, rolling over the hills. She stopped walking. Home could wait a little longer.

5

Worth the long way round.

6

The End

Drag a page corner, tap a page or use the arrow keys

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-page-turn-book to a box holding your pages, one child each, and give it a width.
  3. 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-durationTime for a page to turn by itself, after a tap or a key press. Default 900 ms.
data-autoplayMilliseconds: 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.

1 · Snippet
<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>
2 · Your HTML
<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>