← Hover & cursor

Expanding Panels

A row of tall picture panels where the one under the pointer opens out wide and the rest squeeze up to make room, its caption rising into view. The last one you hovered stays open.

01Lakes
02Aurora
03Dunes
04Nightfall

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-expanding-panels to a row of panels, each holding a picture.
  3. Style the caption of the open panel with the epn-open class the script adds to it.

Options

Add any of these attributes to the same element.

data-growHow many times wider the open panel is than the others. Default 4.
data-activeWhich panel is open to begin with, counting from 0. Default 0.
data-durationHow long the panels take to resize. Default 700 ms.
data-autoplayOpen each panel in turn while the pointer isn’t over the row. Handy for demos and touch screens.

Give the row a height and let pictures cover their panel (object-fit: cover). A tap opens a panel on touch screens, and keyboard focus opens it too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Expanding Panels: in a row of panels, the one under the pointer widens and
    // the rest shrink to make room. The last one hovered stays open.
    // Usage: <div data-expanding-panels><div>…</div><div>…</div><div>…</div></div>
    // Optional: data-grow (how many times wider the open panel is, default 4),
    // data-active (index of the panel open at the start, default 0),
    // data-duration (ms, default 700), data-autoplay (open each panel in turn
    // while the pointer isn't over the row; handy for demos and touch screens).
    // The open panel gets the class "epn-open", for styling its caption.
    var DEFAULTS = { grow: 4, active: 0, duration: 700 };

    var css =
      '.epn{display:flex}' +
      '.epn>*{flex:1 1 0;min-width:0;overflow:hidden;cursor:pointer}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var grow = d.grow !== undefined ? +d.grow : DEFAULTS.grow;
      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      el.classList.add('epn');
      var panels = Array.prototype.slice.call(el.children);
      var state = panels.map(function () { return { g: 1, anim: null }; });
      var open = -1;

      function show(i) {
        if (i === open || i < 0 || i >= panels.length) return;
        open = i;
        panels.forEach(function (p, j) {
          var s = state[j];
          var target = j === i ? grow : 1;
          p.classList.toggle('epn-open', j === i);
          if (s.anim) s.anim.pause();
          if (reduced()) { s.g = target; p.style.flexGrow = target; return; }
          s.anim = anime.animate(s, {
            g: target,
            duration: duration,
            ease: 'inOut(3)',
            onUpdate: function () { p.style.flexGrow = s.g.toFixed(3); }
          });
        });
      }

      var start = d.active !== undefined ? +d.active : DEFAULTS.active;
      open = start;
      panels.forEach(function (p, j) {
        state[j].g = j === start ? grow : 1;
        p.style.flexGrow = state[j].g;
        p.classList.toggle('epn-open', j === start);
        p.addEventListener('pointerenter', function (e) {
          if (e.pointerType === 'touch') return;
          show(j);
        });
        // Touch: a tap opens the panel.
        p.addEventListener('pointerdown', function (e) {
          if (e.pointerType !== 'touch') return;
          el._epnHold = Date.now() + 6000;
          show(j);
        });
        p.addEventListener('focusin', function () { show(j); });
      });
      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') el._epnReal = true; });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') el._epnReal = false; });
      return { show: show, open: function () { return open; }, count: panels.length };
    }

    // Open each panel in turn.
    function autoplay(el, api) {
      var visible = false, timer = null;
      function tick() {
        if (!visible) return;
        var busy = el._epnReal || (el._epnHold && Date.now() < el._epnHold) || el.contains(document.activeElement);
        if (!busy) api.show((api.open() + 1) % api.count);
        timer = setTimeout(tick, busy ? 800 : 2000);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 900); }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-expanding-panels]').forEach(function (el) {
        if (el._epn) return;
        el._epn = true;
        var api = setup(el);
        var auto = el.dataset.autoplay;
        if (auto !== undefined && auto !== 'false' && !reduced()) autoplay(el, api);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="panels" data-expanding-panels>
  <div><img src="lakes.jpg" alt=""><span>Lakes</span></div>
  <div><img src="aurora.jpg" alt=""><span>Aurora</span></div>
  <div><img src="dunes.jpg" alt=""><span>Dunes</span></div>
</div>

<style>
  .panels { height: 320px; gap: 8px; }
  .panels img { width: 100%; height: 100%; object-fit: cover; }
  .panels span { opacity: 0; transition: opacity .4s; }
  .panels .epn-open span { opacity: 1; }
</style>