← Entrances

Minimise to Corner

A panel squeezes down into a small icon in the corner, like a window being minimised into the dock: its height goes first, its width follows, and the icon gives a little bounce as it lands.

NotesShopping list

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-minimise-to-corner to the panel.
  3. Point data-target at the icon it should shrink into, or leave it off and it shrinks into the bottom right corner.

Options

Add any of these attributes to the same element.

data-targetA CSS selector for the icon it shrinks into, like .dock-notes. The nearest match around the panel is used. The icon bounces as it arrives.
data-cornerWith no target, the corner of the nearest clipping container (or the window) it shrinks into: bottom-right, bottom-left, top-right or top-left. Default bottom-right.
data-durationHow long the squeeze takes. Default 700 ms.
data-delayHow long it stays in view before it minimises. Default 1200 ms.
data-loopSpring back out of the icon, hold and minimise again.
data-holdHow long it stays open between goes, when looping. Default 1600 ms.

It ends hidden (visibility: hidden) but still taking up its space, so position the panel absolutely (or fixed) if the layout shouldn’t keep a gap. To play it when you choose, leave the attribute off and call animacious.minimiseToCorner(el); animacious.restoreFromCorner(el) springs it back out of the icon.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Minimise to Corner: a panel squeezes down into a small icon in a
    // corner, like a minimised window going into the dock, and the icon
    // gives a little bounce as it arrives.
    // Usage: <div data-minimise-to-corner data-target=".dock-icon">…</div>
    // It plays once the panel has been in view for data-delay ms, and ends
    // hidden (visibility: hidden) but still taking up its space.
    // Optional: data-target (a CSS selector for the icon it shrinks into; the
    // nearest match around the panel is used), data-corner (with no target,
    // the corner of the nearest clipping container, or of the window, it
    // shrinks into: "bottom-right", default, "bottom-left", "top-right" or
    // "top-left"), data-duration (ms, default 700), data-delay (ms in view
    // before it goes, default 1200), data-loop (spring back out of the icon,
    // hold and minimise again), data-hold (ms to stay open when looping,
    // default 1600).
    // Script use: animacious.minimiseToCorner(el, done) minimises any panel
    // now, and animacious.restoreFromCorner(el, done) brings it back out.
    var DEFAULTS = { corner: 'bottom-right', duration: 700, delay: 1200, hold: 1600 };

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

    // The icon to aim for: the nearest match for data-target around the panel.
    function findTarget(el) {
      var sel = el.dataset.target;
      if (!sel) return null;
      for (var p = el.parentElement; p; p = p.parentElement) {
        var t = p.querySelector(sel);
        if (t && t !== el && !el.contains(t)) return t;
      }
      return null;
    }

    // The box it shrinks into, in viewport px.
    function targetBox(el) {
      var t = findTarget(el);
      if (t) return t.getBoundingClientRect();
      var box = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var s = getComputedStyle(p);
        if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
          box = p.getBoundingClientRect();
          break;
        }
      }
      var c = el.dataset.corner || DEFAULTS.corner;
      var size = 36;
      var x = /left/.test(c) ? box.left + 12 : box.right - 12 - size;
      var y = /top/.test(c) ? box.top + 12 : box.bottom - 12 - size;
      return { left: x, top: y, width: size, height: size };
    }

    // The transform that lays the panel exactly over the target box.
    function pose(el) {
      anime.utils.set(el, { x: 0, y: 0, scaleX: 1, scaleY: 1 });
      var r = el.getBoundingClientRect();
      var g = targetBox(el);
      return {
        x: g.left - r.left,
        y: g.top - r.top,
        scaleX: Math.max(0.01, g.width / (r.width || 1)),
        scaleY: Math.max(0.01, g.height / (r.height || 1))
      };
    }

    function pop(el) {
      var t = findTarget(el);
      if (!t || reduced()) return;
      anime.animate(t, {
        scale: [{ to: 1.22, duration: 160, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .45)' }]
      });
    }

    function minimise(el, done) {
      var t = +el.dataset.duration || DEFAULTS.duration;
      if (reduced()) {
        anime.utils.set(el, { opacity: 0 });
        if (done) done();
        return;
      }
      var p = pose(el);
      el._mtcPose = p;
      el.style.transformOrigin = '0 0';
      anime.utils.set(el, { opacity: 1 });
      // The height goes first and the width follows, so it squeezes rather
      // than simply shrinking; the move lags a touch behind both.
      anime.animate(el, {
        scaleY: { to: p.scaleY, duration: t * 0.75, ease: 'inOut(3)' },
        scaleX: { to: p.scaleX, delay: t * 0.12, duration: t * 0.88, ease: 'inOut(3)' },
        x: { to: p.x, delay: t * 0.08, duration: t * 0.92, ease: 'inOut(2.5)' },
        y: { to: p.y, delay: t * 0.04, duration: t * 0.96, ease: 'inOut(2.5)' },
        opacity: { to: 0, delay: t * 0.72, duration: t * 0.28, ease: 'in(2)' },
        onComplete: function () {
          pop(el);
          if (done) done();
        }
      });
    }

    function restore(el, done) {
      var t = +el.dataset.duration || DEFAULTS.duration;
      el.style.visibility = '';
      if (reduced()) {
        anime.utils.set(el, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 });
        if (done) done();
        return;
      }
      var p = el._mtcPose || pose(el);
      el.style.transformOrigin = '0 0';
      anime.utils.set(el, { x: p.x, y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, opacity: 0 });
      pop(el);
      anime.animate(el, {
        x: { to: 0, duration: t, ease: 'out(3)' },
        y: { to: 0, duration: t, ease: 'out(3)' },
        scaleX: { to: 1, duration: t * 0.9, ease: 'out(3)' },
        scaleY: { to: 1, delay: t * 0.1, duration: t * 0.9, ease: 'out(3)' },
        opacity: { to: 1, duration: t * 0.3, ease: 'out(2)' },
        onComplete: function () {
          el.style.transformOrigin = '';
          if (done) done();
        }
      });
    }

    function cycle(el) {
      var d = el.dataset;
      var s = el._mtc;
      minimise(el, function () {
        if (d.loop === undefined || d.loop === 'false') {
          el.style.visibility = 'hidden';
          return;
        }
        // Sit in the corner a moment, spring back out, then go again once
        // it's on screen.
        setTimeout(function () {
          restore(el, function () {
            setTimeout(function () {
              if (s.visible) cycle(el);
              else s.waiting = true;
            }, +d.hold || DEFAULTS.hold);
          });
        }, 700);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.minimiseToCorner = function (el, done) {
      minimise(el, function () {
        el.style.visibility = 'hidden';
        if (done) done(el);
      });
    };
    window.animacious.restoreFromCorner = function (el, done) {
      restore(el, function () {
        if (done) done(el);
      });
    };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._mtc;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!s.started) {
            s.started = true;
            var wait = e.target.dataset.delay;
            setTimeout(function () { cycle(e.target); }, wait === undefined ? DEFAULTS.delay : +wait || 0);
          } else if (s.waiting) {
            s.waiting = false;
            cycle(e.target);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-minimise-to-corner]').forEach(function (el) {
        if (el._mtc) return;
        el._mtc = {};
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="window" data-minimise-to-corner data-target=".dock-notes">…</div>
<nav class="dock"><img class="dock-notes" src="notes.svg" alt="Notes"></nav>

<!-- or on demand, with a minimise button and the dock icon to restore it: -->
<script>
  minimiseButton.addEventListener('click', function () {
    animacious.minimiseToCorner(win);
  });
  dockIcon.addEventListener('click', function () {
    animacious.restoreFromCorner(win);
  });
</script>