← Numbers

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

  1. Paste the snippet just before </head>.
  2. 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 with font-size.

Options

Add any of these attributes to the same element.

data-fromThe date it starts on. Default a week before.
data-durationHow long the whole run of pages takes. Default 2400 ms.
data-loopGo back to the start and tear through again.
data-holdHow long it rests on the date before looping. Default 2600 ms.
data-langLanguage for month and day names, e.g. fr. Default the page’s.
data-colorColour 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").

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