← Entrances

Row Collapse

A removed list item slides aside and fades, then its row folds shut, so the items below glide up to close the gap instead of jumping.

PPriyaLunch on Friday?9:41
PPromo Weekly48 hours only: 30% off9:12
SSamHere are the photos8:55
BBuild botAll checks passed8:30

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-row-collapse to a list item to remove it once it’s been in view, or
  3. call animacious.rowCollapse(item, done) when it’s deleted, and remove it in done.

Options

Add any of these attributes to the same element.

data-toThe way it slides as it fades: right, left, or none to shrink in place. Default right.
data-durationHow long the slide and the collapse take together. Default 700 ms.
data-delayHow long it stays in view before it goes. Default 1200 ms.
data-loopOpen the gap again, fade back in, hold and collapse again.
data-holdHow long it stays between removals, when looping. Default 1400 ms.

It animates the item’s height, padding, margins and borders down to nothing, then sets display: none. Space your rows with margins rather than a flex or grid gap, as a gap around the item can’t close.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Row Collapse: a removed list item slides aside and fades, then its row
    // folds shut so the items around it glide together to close the gap.
    // Usage: <li data-row-collapse>…</li>
    // It plays once the item has been in view for data-delay ms. Without
    // data-loop it ends hidden (display: none), so call
    // animacious.rowCollapse(el, done) and remove it in done if you like.
    // Optional: data-to (the way it slides as it fades: "right", default,
    // "left", or "none" to shrink in place), data-duration (ms, default 700),
    // data-delay (ms in view before it goes, default 1200), data-loop (open
    // the gap again, fade back in, hold and collapse again), data-hold (ms to
    // stay between removals when looping, default 1400).
    // Script use: animacious.rowCollapse(el, done) plays it on any element
    // now, using its data-* options, and calls done afterwards.
    var DEFAULTS = { to: 'right', duration: 700, delay: 1200, hold: 1400 };
    var BOX = ['height', 'paddingTop', 'paddingBottom', 'marginTop', 'marginBottom', 'borderTopWidth', 'borderBottomWidth'];

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

    // Hold the row at its current size so those sizes can be animated.
    function freeze(el) {
      var cs = getComputedStyle(el);
      var box = {};
      BOX.forEach(function (k) { box[k] = cs[k]; });
      el._rwcBox = box;
      BOX.forEach(function (k) { el.style[k] = box[k]; });
      el.style.minHeight = '0px';
      el.style.overflow = 'hidden';
      return box;
    }

    function unfreeze(el) {
      BOX.concat(['minHeight', 'overflow']).forEach(function (k) { el.style[k] = ''; });
    }

    function leave(el, done) {
      var d = el.dataset;
      var t = +d.duration || DEFAULTS.duration;
      var to = d.to || DEFAULTS.to;
      if (reduced()) {
        el.style.display = 'none';
        if (done) done();
        return;
      }
      anime.utils.set(el, { x: 0, scale: 1, opacity: 1 });
      var slide = to === 'none' ? 0 : Math.min(60, el.offsetWidth * 0.15) * (to === 'left' ? -1 : 1);
      freeze(el);
      var shut = {};
      BOX.forEach(function (k) { shut[k] = '0px'; });
      shut.duration = t * 0.55;
      shut.ease = 'inOut(3)';
      anime.createTimeline({
        onComplete: function () {
          if (done) done();
        }
      })
        // The item shrinks and slides aside…
        .add(el, {
          x: { to: slide, ease: 'in(2)' },
          scale: { to: to === 'none' ? 0.85 : 0.94, ease: 'in(2)' },
          opacity: { to: 0, ease: 'in(1.5)' },
          duration: t * 0.45
        })
        // …then the gap it leaves closes up.
        .add(el, shut);
    }

    function back(el, done) {
      var box = el._rwcBox;
      var open = {};
      BOX.forEach(function (k) { open[k] = box[k]; });
      open.duration = 450;
      open.ease = 'inOut(3)';
      anime.utils.set(el, { x: 0, scale: 0.94 });
      open.onComplete = function () {
        unfreeze(el);
        anime.animate(el, { opacity: 1, scale: 1, duration: 400, ease: 'out(2)', onComplete: done });
      };
      anime.animate(el, open);
    }

    function cycle(el) {
      var d = el.dataset;
      var s = el._rwc;
      leave(el, function () {
        if (d.loop === undefined || d.loop === 'false') {
          unfreeze(el);
          el.style.display = 'none';
          return;
        }
        // Open the gap again, then go again once it's on screen.
        setTimeout(function () {
          back(el, function () {
            setTimeout(function () {
              if (s.visible) cycle(el);
              else s.waiting = true;
            }, +d.hold || DEFAULTS.hold);
          });
        }, 700);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.rowCollapse = function (el, done) {
      leave(el, function () {
        unfreeze(el);
        el.style.display = 'none';
        if (done) done(el);
      });
    };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._rwc;
          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-row-collapse]').forEach(function (el) {
        if (el._rwc) return;
        el._rwc = {};
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ul class="inbox">
  <li>Lunch on Friday?</li>
  <li data-row-collapse>48 hours only: 30% off</li>
  <li>Here are the photos</li>
</ul>

<!-- or on demand, when a delete button is clicked: -->
<script>
  button.addEventListener('click', function () {
    animacious.rowCollapse(item, function () { item.remove(); });
  });
</script>