← Cards & images

Bento Grid Reveal

The tiles of a bento grid build in from the middle outwards: each one unfolds from the edge facing the centre, then its contents rise into place, so the layout seems to assemble itself as it scrolls into view.

Ship it fasterCopy, paste, done.
99%happy
Dark mode
⚡Instant

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bento-grid-reveal to your grid. Each child is a tile; lay them out with your own CSS.

Options

Add any of these attributes to the same element.

data-durationTime for each tile. Default 700 ms.
data-staggerTime between tiles. Default 90 ms.
data-from="start"Build from the top-left corner instead of the centre.
data-loopFold away and build again every few seconds.

Rounded tiles keep their corners as they unfold. With reduced motion the grid simply shows.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bento Grid Reveal: the tiles of a bento grid build in from the middle
    // outwards. Each tile unfolds from the edge facing the centre of the grid,
    // then its contents rise into place, so the layout seems to assemble itself.
    // Usage:
    //   <div data-bento-grid-reveal class="my-bento-grid">
    //     <div>…</div>
    //     <div>…</div>
    //     <div>…</div>
    //   </div>
    // Each child is a tile (lay the grid out with your own CSS). Optional:
    // data-duration (ms for each tile, default 700), data-stagger (ms between
    // tiles, default 90), data-from="start" (build from the top-left corner
    // instead of the centre), data-loop (fold away and build again every few
    // seconds).
    var DEFAULTS = { duration: 700, stagger: 90, hold: 2600 };

    function setup(el) {
      var tiles = Array.from(el.children);
      if (!tiles.length) return;
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var gap = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var plan = [], visible = false, played = false, waiting = false;

      // Work out each tile's opening edge and its place in the order.
      function measure() {
        var g = el.getBoundingClientRect();
        var cx = g.left + g.width / 2, cy = g.top + g.height / 2;
        plan = tiles.map(function (t) {
          var r = t.getBoundingClientRect();
          var dx = (r.left + r.width / 2 - cx) / g.width, dy = (r.top + r.height / 2 - cy) / g.height;
          var radius = getComputedStyle(t).borderTopLeftRadius;
          var round = radius && radius !== '0px' ? ' round ' + radius : '';
          // Clipped to a sliver along the edge nearest the centre of the grid.
          var shut = Math.abs(dx) > Math.abs(dy)
            ? (dx > 0 ? 'inset(0% 100% 0% 0%' : 'inset(0% 0% 0% 100%')
            : (dy > 0 ? 'inset(0% 0% 100% 0%' : 'inset(100% 0% 0% 0%');
          return {
            t: t,
            kids: Array.from(t.children),
            shut: shut + round + ')',
            // Same units as the shut shape, so the two tween smoothly.
            open: 'inset(0% 0% 0% 0%' + round + ')',
            push: { x: dx * 40, y: dy * 40 },
            order: d.from === 'start' ? r.top - g.top + (r.left - g.left) * 0.6 : Math.hypot(dx, dy)
          };
        });
        var sorted = plan.slice().sort(function (a, b) { return a.order - b.order; });
        sorted.forEach(function (p, i) { p.at = i * gap; });
      }

      function hide() {
        plan.forEach(function (p) {
          // Kept at a sliver (not fully clipped) so the grid still measures and scrolls.
          anime.utils.set(p.t, { clipPath: p.shut, opacity: 0, x: -p.push.x, y: -p.push.y, scale: 0.94 });
          if (p.kids.length) anime.utils.set(p.kids, { opacity: 0, y: 14 });
        });
      }

      function play() {
        anime.utils.set(tiles, { x: 0, y: 0, scale: 1 });
        measure();
        hide();
        var tl = anime.createTimeline({ onComplete: function () { if (d.loop !== undefined) setTimeout(fold, DEFAULTS.hold); } });
        plan.forEach(function (p) {
          tl.add(p.t, { opacity: { to: 1, duration: duration * 0.4, ease: 'out(2)' }, clipPath: [p.shut, p.open], x: 0, y: 0, scale: 1, duration: duration, ease: 'out(4)' }, p.at);
          if (p.kids.length) tl.add(p.kids, { opacity: 1, y: 0, duration: duration * 0.8, ease: 'out(3)', delay: anime.stagger(70) }, p.at + duration * 0.45);
        });
      }

      // Looping: the tiles fold back towards the centre together, then build again.
      function fold() {
        if (!visible || el.closest('[hidden]')) { waiting = true; return; }
        var tl = anime.createTimeline({ onComplete: function () { setTimeout(play, 300); } });
        plan.slice().sort(function (a, b) { return b.order - a.order; }).forEach(function (p, i) {
          tl.add(p.t, { clipPath: p.shut, opacity: 0, scale: 0.94, duration: duration * 0.6, ease: 'in(3)' }, i * gap * 0.4);
        });
      }

      measure();
      hide();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) return;
        if (!played) { played = true; play(); }
        else if (waiting) { waiting = false; fold(); }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-bento-grid-reveal]').forEach(function (el) {
        if (el._bento) return;
        el._bento = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-bento-grid-reveal class="my-bento">
  <div>…</div>
  <div>…</div>
  <div>…</div>
  <div>…</div>
</div>