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
- Paste the snippet just before
</head>. - Add
data-page-curl-cornerto a card. - 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-corner | bottom-right, bottom-left, top-right or top-left. Default bottom-right. |
data-size | How far the corner curls, in px. Default 30% of the card’s shorter side. |
data-rest | A small curl that’s always there, as a hint. Default 0 px. |
data-back | Colour of the paper’s back. Default #f4f1ea. |
data-duration | Time to curl. Default 600 ms. |
data-autoplay | Milliseconds: 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.
<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><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>