← Cards & images

Page Curl Corner

Move the pointer over the card and one corner curls up like the corner of a page about to be turned, showing the paper’s back and whatever lies underneath. Move away and it drops flat with a little flutter.

Chapter 2 →
Chapter 1

It was a bright cold day in April, and the clocks were striking thirteen. Down the hall, someone was already reaching for the corner of the page.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-page-curl-corner to a card.
  3. Put whatever should peek out from under the corner behind it: the curl shows what’s underneath.

Options

Add any of these attributes to the same element.

data-cornerbottom-right, bottom-left, top-right or top-left. Default bottom-right.
data-sizeHow far the corner curls, in px. Default 30% of the card’s shorter side.
data-restA small curl that’s always there, as a hint. Default 0 px.
data-backColour of the paper’s back. Default #f4f1ea.
data-durationTime to curl. Default 600 ms.
data-autoplayMilliseconds: curl and flatten on a timer until someone interacts.

The corner is cut away with clip-path, which also cuts off a box-shadow, so give the card’s wrapper a filter: drop-shadow(…) instead, as this demo does. On touch screens a tap curls or flattens it, and keyboard focus curls it too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Page Curl Corner: one corner of a card curls up like the corner of a page
    // when the pointer moves over it, showing the paper's back and a shadow,
    // and settles flat again when it leaves. On touch screens a tap curls it.
    // Usage: <div data-page-curl-corner>…your card…</div>
    // Optional: data-corner ("bottom-right", default, "bottom-left", "top-right"
    // or "top-left"), data-size (how far it curls, px, default 30% of the
    // card's shorter side), data-rest (a small curl that's always there as a
    // hint, px, default 0), data-back (colour of the paper's back, default
    // #f4f1ea), data-duration (ms, default 600), data-autoplay (ms between
    // curls, until someone interacts).
    var DEFAULTS = { duration: 600, rest: 0, back: '#f4f1ea' };

    var css =
      '.pcc{position:relative;--pcc-s:0px}' +
      '.pcc-flap{position:absolute;width:var(--pcc-s);height:var(--pcc-s);pointer-events:none;z-index:5;border-radius:0}' +
      '.pcc-shade{position:absolute;inset:0;pointer-events:none;z-index:4;border-radius:inherit}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    var CORNERS = {
      'bottom-right': { v: 'bottom', h: 'right', to: 'to bottom right', sx: 1, sy: 1 },
      'bottom-left': { v: 'bottom', h: 'left', to: 'to bottom left', sx: -1, sy: 1 },
      'top-right': { v: 'top', h: 'right', to: 'to top right', sx: 1, sy: -1 },
      'top-left': { v: 'top', h: 'left', to: 'to top left', sx: -1, sy: -1 }
    };

    // Clip the card so the corner square's outer half is cut away: the flap
    // (its folded-over back) covers the inner half.
    function clipFor(c, s) {
      var S = s.toFixed(1) + 'px';
      var R = 'calc(100% - ' + S + ')';
      if (c.v === 'bottom' && c.h === 'right') return 'polygon(0 0,100% 0,100% ' + R + ',' + R + ' 100%,0 100%)';
      if (c.v === 'bottom') return 'polygon(0 0,100% 0,100% 100%,' + S + ' 100%,0 ' + R + ')';
      if (c.h === 'right') return 'polygon(0 0,' + R + ' 0,100% ' + S + ',100% 100%,0 100%)';
      return 'polygon(' + S + ' 0,100% 0,100% 100%,0 100%,0 ' + S + ')';
    }

    function setup(el) {
      var d = el.dataset;
      var c = CORNERS[d.corner] || CORNERS['bottom-right'];
      var back = d.back || DEFAULTS.back;
      el.classList.add('pcc');
      var flap = document.createElement('span');
      flap.className = 'pcc-flap';
      flap.setAttribute('aria-hidden', 'true');
      flap.style[c.v] = '0';
      flap.style[c.h] = '0';
      // The back of the page: lighter at the tip, shaded where it bends at the crease.
      flap.style.background = 'linear-gradient(' + c.to + ',' + back + ' 0%,#fff 22%,' + back + ' 38%,rgba(0,0,0,.18) 49.5%,transparent 50%)';
      flap.style.filter = 'drop-shadow(' + (-c.sx * 3) + 'px ' + (-c.sy * 3) + 'px 4px rgba(15,15,30,.28))';
      el.appendChild(flap);
      // A faint shadow on the card just inside the crease.
      var shade = document.createElement('span');
      shade.className = 'pcc-shade';
      shade.setAttribute('aria-hidden', 'true');
      el.appendChild(shade);

      var s = el._pccS = { c: c, flap: flap, shade: shade, v: { s: 0 }, anim: null, open: false, timer: 0 };
      var rest = d.rest !== undefined ? +d.rest : DEFAULTS.rest;
      s.rest = rest;
      draw(el, rest);

      el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stop(el); curl(el, true); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') curl(el, false); });
      el.addEventListener('pointerdown', function (e) { if (e.pointerType !== 'mouse') { stop(el); curl(el, !s.open); } });
      el.addEventListener('focusin', function () { curl(el, true); });
      el.addEventListener('focusout', function () { curl(el, false); });

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var every = +d.autoplay || 2600;
        s.timer = setInterval(function () { if (s.visible) curl(el, !s.open); }, every);
        new IntersectionObserver(function (en) { s.visible = en[0].isIntersecting; }).observe(el);
      }
    }

    function stop(el) {
      var s = el._pccS;
      if (s.timer) { clearInterval(s.timer); s.timer = 0; }
    }

    function size(el) {
      var d = el.dataset;
      if (d.size) return +d.size;
      return Math.round(Math.min(el.offsetWidth, el.offsetHeight) * 0.3);
    }

    function draw(el, v) {
      var s = el._pccS, c = s.c;
      v = Math.max(0, v);
      s.v.s = v;
      el.style.setProperty('--pcc-s', v.toFixed(1) + 'px');
      el.style.clipPath = v > 0.5 ? clipFor(c, v) : '';
      var reach = (v * 1.15).toFixed(1) + 'px';
      var at = c.v + ' ' + c.h;
      s.shade.style.background = v > 0.5
        ? 'radial-gradient(circle at ' + at + ',rgba(15,15,30,.16),transparent ' + reach + ')'
        : 'none';
    }

    function curl(el, open) {
      var s = el._pccS;
      if (!s) return;
      s.open = open;
      var target = open ? size(el) : s.rest;
      if (s.anim) s.anim.pause();
      if (reduced()) { draw(el, target); return; }
      var dur = +el.dataset.duration || DEFAULTS.duration;
      s.anim = anime.animate(s.v, {
        s: target,
        duration: open ? dur : dur * 1.2,
        ease: open ? 'out(3)' : 'outElastic(1, .55)',
        onUpdate: function () { draw(el, s.v.s); }
      });
    }

    function init() {
      document.querySelectorAll('[data-page-curl-corner]').forEach(function (el) {
        if (el._pcc) return;
        el._pcc = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div style="position: relative; filter: drop-shadow(0 8px 16px rgb(0 0 0 / .15))">
  <div class="next-page">Chapter 2 →</div>
  <div class="page" data-page-curl-corner data-rest="14">
    <h3>Chapter 1</h3>
    <p>It was a bright cold day in April…</p>
  </div>
</div>