← Diagrams & maps

Calendar Events Drop

Events fall into a weekly calendar one after another. Each one tumbles in from the top, straightens up as it drops, lands in its slot with a little squash and a flash of colour, and the week fills up in front of you. A red line marks the time now.

Standup Roadmap 1:1 with Sam Focus time Standup Lunch & learn Design crit Workshop Hiring Standup Demo day Retro

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-calendar-events-drop to a container, with one child per event.
  3. Give each event data-day (1 for the first column) and data-time as start-end in 24-hour time, such as 9:30-11. Its text is the title.

Options

Add any of these attributes to the same element.

data-day / data-timeOn an event: its column, and when it starts and ends.
data-daysColumn names. Default Mon,Tue,Wed,Thu,Fri.
data-hoursFirst and last hour shown. Default 9-17.
data-colorEvent colour, on the container or on a single event. Default #7c3aed.
data-todayColumn to mark as today, counting from 1.
data-nowWith data-today: draw a “now” line at this time, such as 11:20.
data-staggerGap between events dropping. Default 260 ms.
data-loopLift the events off and drop them again.

Events of an hour or more also show their times. Text size follows the container’s font-size; each hour is 2.3em tall.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Calendar Events Drop: events fall into a weekly calendar one after
    // another, landing in their slots with a little squash and a flash.
    // Usage:
    //   <div data-calendar-events-drop data-hours="9-17">
    //     <span data-day="1" data-time="9:30-10:30">Team standup</span>
    //     <span data-day="3" data-time="13-15" data-color="#db2777">Design review</span>
    //   </div>
    // Each event is a child with data-day (1 for the first column) and
    // data-time ("start-end" in 24-hour time, as 9, 9:30 or 9.5); its text is
    // the title.
    // Optional: data-days (column names, default Mon,Tue,Wed,Thu,Fri),
    // data-hours (first and last hour shown, default 9-17), data-color (event
    // colour, default #7c3aed; also on a single event), data-now (draw a
    // "now" line at this time, e.g. 11:15, on data-today's column),
    // data-today (column to mark as today, 1-based), data-stagger (ms between
    // events, default 260), data-loop (lift the events off and drop them
    // again).
    var DEFAULTS = { days: 'Mon,Tue,Wed,Thu,Fri', hours: '9-17', color: '#7c3aed', stagger: 260 };

    var css =
      '.ced{display:grid;grid-template-columns:2.8em minmax(0,1fr);width:100%;line-height:1.2;background:#fff;border-radius:.8em;box-shadow:0 .3em 1.2em rgba(40,20,80,.1);padding:.6em .6em .6em 0;box-sizing:border-box}' +
      '.ced-head{grid-column:2;display:grid;border-bottom:1px solid #e9e4f2;padding-bottom:.35em}' +
      '.ced-head span{text-align:center;font-weight:700;font-size:.8em;color:#7a7090;letter-spacing:.02em}' +
      '.ced-head span.ced-today{color:var(--ced-c)}' +
      '.ced-head span.ced-today::after{content:"";display:block;width:.35em;height:.35em;margin:.2em auto 0;border-radius:50%;background:var(--ced-c)}' +
      '.ced-hrs{position:relative}' +
      '.ced-hrs span{position:absolute;right:.6em;translate:0 -50%;font-size:.68em;color:#a49bb6;font-variant-numeric:tabular-nums;white-space:nowrap}' +
      '.ced-body{position:relative;overflow:hidden;background-image:linear-gradient(#efebf6 1px,transparent 1px);border-left:1px solid #efebf6}' +
      '.ced-col{position:absolute;top:0;bottom:0;border-right:1px solid #efebf6}' +
      '.ced-col.ced-today{background:color-mix(in srgb,var(--ced-c) 5%,transparent)}' +
      '.ced-ev{position:absolute;box-sizing:border-box;padding:.25em .35em .2em .45em;border-radius:.35em;border-left:.25em solid var(--ced-e);background:color-mix(in srgb,var(--ced-e) 16%,#fff);overflow:hidden;opacity:0;transform-origin:50% 100%;' +
        'box-shadow:0 .1em .3em color-mix(in srgb,var(--ced-e) 25%,transparent)}' +
      '.ced-ev b{display:block;font-size:.72em;font-weight:700;color:color-mix(in srgb,var(--ced-e) 70%,#1c1230);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.ced-ev i{display:block;font-style:normal;font-size:.62em;color:color-mix(in srgb,var(--ced-e) 55%,#4b4060);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.ced-flash{position:absolute;border-radius:.35em;background:var(--ced-e);opacity:0;pointer-events:none}' +
      '.ced-now{position:absolute;height:2px;margin-top:-1px;background:#ef4444;transform-origin:0 50%}' +
      '.ced-now::before{content:"";position:absolute;left:-.3em;top:50%;width:.6em;height:.6em;margin-top:-.3em;border-radius:50%;background:#ef4444}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function hour(t) {
      t = String(t).trim();
      var m = t.match(/^(\d{1,2})(?::(\d{2}))?$/);
      return m ? +m[1] + (m[2] ? +m[2] / 60 : 0) : parseFloat(t);
    }

    function clock(h) {
      var hh = Math.floor(h), mm = Math.round((h - hh) * 60);
      return hh + ':' + (mm < 10 ? '0' : '') + mm;
    }

    function setup(el) {
      var d = el.dataset;
      var s = (el._ced = { events: [] });
      var days = (d.days || DEFAULTS.days).split(',').map(function (x) { return x.trim(); });
      var hr = (d.hours || DEFAULTS.hours).split('-').map(hour);
      var h0 = hr[0], h1 = Math.max(h0 + 1, hr[1] || h0 + 8), span = h1 - h0;
      var today = +d.today || 0;
      var kids = Array.from(el.children);
      el.classList.add('ced');
      el.style.setProperty('--ced-c', d.color || DEFAULTS.color);
      var cols = 'repeat(' + days.length + ',minmax(0,1fr))';

      var head = document.createElement('div');
      head.className = 'ced-head';
      head.style.gridTemplateColumns = cols;
      days.forEach(function (name, i) {
        var c = document.createElement('span');
        c.textContent = name;
        if (i + 1 === today) c.className = 'ced-today';
        head.appendChild(c);
      });
      var hrs = document.createElement('div');
      hrs.className = 'ced-hrs';
      hrs.setAttribute('aria-hidden', 'true');
      var body = document.createElement('div');
      body.className = 'ced-body';
      body.style.height = span * 2.3 + 'em';
      body.style.backgroundSize = '100% ' + 100 / span + '%';
      for (var h = Math.ceil(h0); h < h1; h++) {
        var lab = document.createElement('span');
        lab.style.top = ((h - h0) / span) * 100 + '%';
        lab.textContent = h + ':00';
        if (h > h0) hrs.appendChild(lab);
      }
      days.forEach(function (name, i) {
        var col = document.createElement('div');
        col.className = 'ced-col' + (i + 1 === today ? ' ced-today' : '');
        col.style.left = (i / days.length) * 100 + '%';
        col.style.width = 100 / days.length + '%';
        body.appendChild(col);
      });
      el.appendChild(document.createElement('span'));
      el.appendChild(head);
      el.appendChild(hrs);
      el.appendChild(body);

      kids.forEach(function (k) {
        var day = +k.getAttribute('data-day') || 1;
        var t = (k.getAttribute('data-time') || '').split('-').map(hour);
        var a = isNaN(t[0]) ? h0 : t[0], b = isNaN(t[1]) ? a + 1 : t[1];
        var title = k.textContent.trim();
        var ev = document.createElement('div');
        ev.className = 'ced-ev';
        ev.style.setProperty('--ced-e', k.getAttribute('data-color') || d.color || DEFAULTS.color);
        ev.style.left = 'calc(' + ((day - 1) / days.length) * 100 + '% + 2px)';
        ev.style.width = 'calc(' + 100 / days.length + '% - 4px)';
        ev.style.top = 'calc(' + ((a - h0) / span) * 100 + '% + 1px)';
        ev.style.height = 'calc(' + ((b - a) / span) * 100 + '% - 2px)';
        ev.innerHTML = '<b></b>' + (b - a >= 1 ? '<i></i>' : '');
        ev.firstChild.textContent = title;
        if (b - a >= 1) ev.lastChild.textContent = clock(a) + '–' + clock(b);
        ev.setAttribute('aria-label', title + ', ' + (days[day - 1] || '') + ' ' + clock(a) + ' to ' + clock(b));
        ev.setAttribute('role', 'note');
        var flash = document.createElement('div');
        flash.className = 'ced-flash';
        flash.setAttribute('aria-hidden', 'true');
        ['left', 'width', 'top', 'height'].forEach(function (p) { flash.style[p] = ev.style[p]; });
        flash.style.setProperty('--ced-e', ev.style.getPropertyValue('--ced-e'));
        body.appendChild(flash);
        body.appendChild(ev);
        k.remove();
        s.events.push({ el: ev, flash: flash });
      });
      if (d.now && today) {
        var n = hour(d.now);
        if (n >= h0 && n <= h1) {
          s.now = document.createElement('div');
          s.now.className = 'ced-now';
          s.now.setAttribute('aria-hidden', 'true');
          s.now.style.top = ((n - h0) / span) * 100 + '%';
          s.now.style.left = ((today - 1) / days.length) * 100 + '%';
          s.now.style.width = 100 / days.length + '%';
          body.appendChild(s.now);
        }
      }
      s.body = body;
    }

    function play(el) {
      var s = el._ced, stagger = +el.dataset.stagger || DEFAULTS.stagger, last = 0;
      s.events.forEach(function (e, i) {
        var fall = e.el.offsetTop + e.el.offsetHeight + 12, delay = 300 + i * stagger;
        anime.utils.set(e.el, { opacity: 1, y: -fall, scaleX: 1, scaleY: 1, rotate: (Math.random() - 0.5) * 8 });
        var tl = anime.createTimeline({ delay: delay });
        tl.add(e.el, { y: [-fall, 0], rotate: 0, duration: 420 + fall * 0.6, ease: 'in(2.2)' }, 0)
          .add(e.el, { scaleY: [1, 0.86, 1.04, 1], scaleX: [1, 1.05, 0.99, 1], duration: 460, ease: 'out(2)' }, '<')
          .add(e.flash, { opacity: [0.45, 0], scale: [1, 1.12], duration: 600, ease: 'out(3)' }, '<<');
        last = Math.max(last, delay + 420 + fall * 0.6 + 460);
      });
      if (s.now) anime.animate(s.now, { scaleX: [0, 1], opacity: [0, 1], duration: 600, delay: 200, ease: 'out(3)' });
      if (el.dataset.loop === undefined) return;
      setTimeout(function () {
        s.events.forEach(function (e, i) {
          anime.animate(e.el, { y: -(e.el.offsetTop + e.el.offsetHeight + 12), opacity: [1, 0.4], duration: 420, delay: i * 50, ease: 'in(3)' });
        });
        setTimeout(function () { play(el); }, 420 + s.events.length * 50 + 300);
      }, last + 2800);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-calendar-events-drop]').forEach(function (el) {
        if (el._ced) return;
        setup(el);
        if (reduced()) el._ced.events.forEach(function (e) { e.el.style.opacity = 1; });
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-calendar-events-drop data-hours="9-17" data-today="3" data-now="11:20">
  <span data-day="1" data-time="9-10">Standup</span>
  <span data-day="2" data-time="10-11:30" data-color="#db2777">1:1 with Sam</span>
  <span data-day="4" data-time="13-15" data-color="#0ea5e9">Workshop</span>
</div>