Tear-off Calendar
A desk calendar block whose pages flip up and tear away one day at a time, quickly at first and slowing down for the last few, until the chosen date shows. Each page has the month, a big day number and the weekday, with weekends in red.
How to use it
- Paste the snippet just before
</head>. - Add
data-tear-off-calendar="2026-12-25"to an empty element: the date to land on. Leave the value empty for today. Size it withfont-size.
Options
Add any of these attributes to the same element.
data-from | The date it starts on. Default a week before. |
data-duration | How long the whole run of pages takes. Default 2400 ms. |
data-loop | Go back to the start and tear through again. |
data-hold | How long it rests on the date before looping. Default 2600 ms. |
data-lang | Language for month and day names, e.g. fr. Default the page’s. |
data-color | Colour of the month strip and weekends. Default #e5484d. |
Long runs skip days so there are never more than 40 pages to tear. Tear forward from a script with el._toc.go("2027-01-01").
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tear-off Calendar: a desk calendar block whose pages flip up and tear away
// one day at a time, quickly at first and slowing down, until the chosen
// date shows. Each page has the month, a big day number and the weekday.
// Usage: <div data-tear-off-calendar="2026-12-25"></div> (empty = today)
// Optional: data-from (the date it starts on, default a week before),
// data-duration (ms for the whole run, default 2400), data-loop (go back
// to the start and tear through again), data-hold (ms it rests on the date
// before looping, default 2600), data-lang (language for month and day
// names, default the page's), data-color (header colour, default #e5484d).
// Size it with font-size: the block is 6em wide.
// Script use: el._toc.go('2027-01-01') tears forward to another date.
var DEFAULTS = { duration: 2400, hold: 2600, color: '#e5484d', back: 6, most: 40 };
var DAY = 86400000;
var css =
'.toc{position:relative;display:inline-block;width:6em;height:7em;perspective:22em;font-variant-numeric:tabular-nums;--toc-c:#e5484d}' +
// The block of pages under the top one: a few offset edges.
'.toc-block{position:absolute;inset:.55em 0 0;border-radius:.22em;background:#fff;' +
'box-shadow:0 .05em 0 #ece8f1,0 .1em 0 #fff,0 .15em 0 #e4dfea,0 .2em 0 #fff,0 .25em 0 #dcd6e3,0 .5em 1.2em rgba(40,20,70,.18)}' +
'.toc-page{position:absolute;inset:.55em 0 0;border-radius:.22em;background:#fff;overflow:hidden;display:flex;flex-direction:column;' +
'align-items:center;color:#1c1a22;transform-origin:50% 0;backface-visibility:hidden;will-change:transform}' +
'.toc-page.toc-top{box-shadow:0 .02em .06em rgba(0,0,0,.08)}' +
'.toc-month{width:100%;padding:.8em 0 .45em;background:var(--toc-c);color:#fff;font-weight:700;font-size:.5em;letter-spacing:.1em;' +
'text-transform:uppercase;text-align:center;white-space:nowrap}' +
'.toc-month small{font-size:1em;font-weight:500;opacity:.75;margin-left:.4em}' +
'.toc-day{flex:1;display:grid;place-items:center;font-size:3.1em;font-weight:800;letter-spacing:-.05em;line-height:1}' +
'.toc-wd{padding-bottom:.9em;font-size:.56em;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:#8a8494}' +
'.toc-wd.toc-we{color:var(--toc-c)}' +
// Binding strip with two rings, and the torn stubs of earlier pages under it.
'.toc-bind{position:absolute;left:-.12em;right:-.12em;top:0;height:.72em;border-radius:.2em;z-index:3;' +
'background:linear-gradient(#3b3946,#25232d);box-shadow:0 .08em .16em rgba(0,0,0,.25)}' +
'.toc-bind::before,.toc-bind::after{content:"";position:absolute;top:-.22em;width:.24em;height:.62em;border-radius:.12em;' +
'background:linear-gradient(90deg,#9a98a6,#e8e6ee 45%,#8d8b99);box-shadow:0 .04em .06em rgba(0,0,0,.3)}' +
'.toc-bind::before{left:1.3em}.toc-bind::after{right:1.3em}' +
'.toc-stub{position:absolute;left:0;right:0;top:.66em;height:.16em;z-index:2;background:#fff;' +
'-webkit-mask:conic-gradient(from -45deg at 50% 100%,#000 90deg,transparent 0) 0 0/.2em .16em repeat-x;' +
'mask:conic-gradient(from -45deg at 50% 100%,#000 90deg,transparent 0) 0 0/.2em .16em repeat-x}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// "2026-12-25" → a local date at midnight (not UTC, so the day doesn't slip).
function parse(s) {
var m = String(s || '').match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
var d = m ? new Date(+m[1], m[2] - 1, +m[3]) : new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
function daysBetween(a, b) { return Math.round((b - a) / DAY); }
function page(c, date) {
var p = document.createElement('div');
p.className = 'toc-page';
p.setAttribute('aria-hidden', 'true');
var wd = date.getDay();
p.innerHTML =
'<div class="toc-month">' + c.fmtMonth.format(date) + '<small>' + date.getFullYear() + '</small></div>' +
'<div class="toc-day">' + date.getDate() + '</div>' +
'<div class="toc-wd' + (wd === 0 || wd === 6 ? ' toc-we' : '') + '">' + c.fmtDay.format(date) + '</div>';
return p;
}
// Show `date` on the top page.
function lay(c, date) {
if (c.top) c.top.remove();
if (c.under) c.under.remove();
c.under = null;
c.top = page(c, date);
c.top.classList.add('toc-top');
c.el.insertBefore(c.top, c.stub);
c.date = date;
c.el.setAttribute('aria-label', c.fmtFull.format(date));
}
// Tear the top page off: it lifts at one corner, flips up over the binding
// and drops away, spinning a little.
function tear(c, ms, done) {
var p = c.top, side = Math.random() < 0.5 ? -1 : 1;
p.style.zIndex = 1;
p.style.transformOrigin = (side < 0 ? '100%' : '0%') + ' 0';
var tl = anime.createTimeline({ onComplete: function () { p.remove(); done(); } });
tl.add(p, { rotate: side * 7, rotateX: -18, duration: ms * 0.35, ease: 'out(2)' })
.add(p, {
rotate: side * 34, rotateX: -95, y: '-16%', x: side * 40 + '%', opacity: 0,
duration: ms * 0.65, ease: 'in(2)'
});
c.anim = tl;
}
// Tear through to `target`, fast at first and easing off for the last pages.
function run(c, target) {
clearTimeout(c.timer);
if (c.anim) c.anim.pause();
var total = daysBetween(c.date, target);
if (total <= 0) { lay(c, target); return; }
// Long runs skip days so there are never more than `most` pages to tear.
var pages = Math.min(total, DEFAULTS.most);
var dates = [];
for (var i = 1; i <= pages; i++) dates.push(addDays(c.date, Math.round(total * i / pages)));
var weights = dates.map(function (_, i) { var t = (i + 0.5) / pages; return 0.35 + 2.2 * Math.pow(t, 3); });
var sum = weights.reduce(function (a, b) { return a + b; }, 0);
var i2 = 0;
(function next() {
if (i2 >= dates.length) { c.running = false; if (c.onDone) c.onDone(); return; }
var date = dates[i2], ms = Math.max(120, c.duration * weights[i2] / sum);
i2++;
// Lay the page that will show underneath before tearing the top one.
if (c.under) c.under.remove();
c.under = page(c, date);
c.el.insertBefore(c.under, c.top);
tear(c, ms, function () {
c.top = c.under;
c.top.classList.add('toc-top');
c.under = null;
c.date = date;
c.el.setAttribute('aria-label', c.fmtFull.format(date));
next();
});
})();
c.running = true;
}
function cycle(c) {
clearTimeout(c.timer);
if (!c.visible) return;
lay(c, c.from);
c.onDone = function () {
if (c.loop) c.timer = setTimeout(function () { cycle(c); }, c.hold);
};
c.timer = setTimeout(function () { run(c, c.target); }, 500);
}
function setup(el) {
var d = el.dataset, lang = d.lang || document.documentElement.lang || undefined;
var target = parse(el.getAttribute('data-tear-off-calendar'));
var c = {
el: el, target: target, from: d.from ? parse(d.from) : addDays(target, -DEFAULTS.back),
duration: +d.duration || DEFAULTS.duration, hold: +d.hold || DEFAULTS.hold,
loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, started: false,
fmtMonth: new Intl.DateTimeFormat(lang, { month: 'long' }),
fmtDay: new Intl.DateTimeFormat(lang, { weekday: 'long' }),
fmtFull: new Intl.DateTimeFormat(lang, { dateStyle: 'full' })
};
if (c.from > c.target) c.from = c.target;
el.classList.add('toc');
el.setAttribute('role', 'img');
if (d.color) el.style.setProperty('--toc-c', d.color);
el.innerHTML = '<div class="toc-block" aria-hidden="true"></div><div class="toc-stub" aria-hidden="true"></div><div class="toc-bind" aria-hidden="true"></div>';
c.stub = el.querySelector('.toc-stub');
lay(c, c.still ? c.target : c.from);
c.go = function (when) {
var t = parse(when);
if (c.still || t <= c.date) lay(c, t);
else run(c, t);
};
return c;
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._toc;
c.visible = e.isIntersecting;
if (!c.visible) { if (c.loop && !c.running) clearTimeout(c.timer); return; }
if (!c.started || (c.loop && !c.running)) { c.started = true; cycle(c); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-tear-off-calendar]').forEach(function (el) {
if (el._toc) return;
var c = (el._toc = setup(el));
if (!c.still) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-tear-off-calendar="2026-12-25" style="font-size: 32px"></div>
<!-- today, in French, tearing over from the first of the month: -->
<div data-tear-off-calendar data-from="2026-11-01" data-lang="fr"></div>