← Page transitions

Tab Panel Slide

A pill glides under the chosen tab and the content slides in from the side you’re heading towards, so switching sections feels spatial rather than abrupt.

01 Sketch the idea
02 Make it real
03 Put it out there

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tab-panel-slide to a container with two children: a row of buttons, then the panels in the same order.

Options

Add any of these attributes to the same element.

data-autoplayMilliseconds: cycle through the tabs until the visitor picks one.
--tps-pill, --tps-activeCSS variables for the pill colour and the active tab’s text colour.

Tabs get the right ARIA roles automatically and work with the arrow keys.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tab Panel Slide: a pill glides under the chosen tab and the content slides
    // in from the side you're heading towards, so switching tabs feels spatial.
    // Usage:
    //   <div data-tab-panel-slide>
    //     <div> <button>Design</button> <button>Build</button> <button>Ship</button> </div>
    //     <div> <div>Design panel…</div> <div>Build panel…</div> <div>Ship panel…</div> </div>
    //   </div>
    // Optional: data-autoplay (ms; cycles through tabs until the visitor clicks one).
    // Colours: --tps-pill and --tps-active CSS variables on the container.

    var css =
      '.tps-bar{position:relative;display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(0,0,0,.05)}' +
      '.tps-bar>button{position:relative;z-index:1;border:0;background:none;font:inherit;font-weight:600;padding:8px 18px;border-radius:999px;cursor:pointer;color:inherit;opacity:.65;transition:opacity .2s,color .2s}' +
      '.tps-bar>button[aria-selected="true"]{opacity:1;color:var(--tps-active,#fff)}' +
      '.tps-pill{position:absolute;top:4px;bottom:4px;left:0;border-radius:999px;background:var(--tps-pill,#6a0dad);z-index:0}' +
      '.tps-panels{display:grid;overflow:hidden}' +
      '.tps-panels>*{grid-area:1/1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el, uid) {
      var bar = el.children[0], wrap = el.children[1];
      if (!bar || !wrap) return;
      var tabs = Array.from(bar.children), panels = Array.from(wrap.children);
      bar.classList.add('tps-bar');
      bar.setAttribute('role', 'tablist');
      wrap.classList.add('tps-panels');
      var pill = document.createElement('span');
      pill.className = 'tps-pill';
      pill.setAttribute('aria-hidden', 'true');
      bar.insertBefore(pill, bar.firstChild);
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var current = 0;

      tabs.forEach(function (t, i) {
        t.setAttribute('role', 'tab');
        t.setAttribute('type', 'button');
        t.id = t.id || 'tps-' + uid + '-tab-' + i;
        var p = panels[i];
        if (p) {
          p.setAttribute('role', 'tabpanel');
          p.id = p.id || 'tps-' + uid + '-panel-' + i;
          p.setAttribute('aria-labelledby', t.id);
          t.setAttribute('aria-controls', p.id);
        }
      });

      function placePill(animate) {
        var t = tabs[current];
        var props = { x: t.offsetLeft, width: t.offsetWidth };
        if (!animate || reduced) return anime.utils.set(pill, { x: props.x, width: props.width + 'px' });
        anime.animate(pill, { x: props.x, width: props.width + 'px', duration: 500, ease: 'outBack(1.2)' });
      }

      function select(i, animate) {
        var prev = current;
        current = i;
        tabs.forEach(function (t, n) {
          t.setAttribute('aria-selected', n === i);
          t.tabIndex = n === i ? 0 : -1;
        });
        panels.forEach(function (p, n) { p.style.visibility = n === i || (animate && n === prev) ? 'visible' : 'hidden'; });
        placePill(animate);
        if (!animate || reduced || prev === i) return;
        var dir = i > prev ? 1 : -1;
        anime.animate(panels[prev], {
          x: ['0%', -dir * 30 + '%'], opacity: [1, 0], duration: 350, ease: 'in(2)',
          onComplete: function () { if (current !== prev) panels[prev].style.visibility = 'hidden'; anime.utils.set(panels[prev], { x: 0, opacity: 1 }); }
        });
        anime.animate(panels[i], { x: [dir * 30 + '%', '0%'], opacity: [0, 1], duration: 550, delay: 120, ease: 'out(3)' });
      }

      var auto = null;
      function stopAuto() { clearInterval(auto); auto = null; }
      tabs.forEach(function (t, i) {
        t.addEventListener('click', function () { stopAuto(); select(i, true); });
        t.addEventListener('keydown', function (e) {
          var n = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
          if (!n) return;
          e.preventDefault();
          stopAuto();
          var j = (current + n + tabs.length) % tabs.length;
          select(j, true);
          tabs[j].focus();
        });
      });
      select(0, false);
      window.addEventListener('resize', function () { placePill(false); });
      if (document.fonts) document.fonts.ready.then(function () { placePill(false); });

      var every = +el.dataset.autoplay;
      if (every && !reduced) auto = setInterval(function () { select((current + 1) % tabs.length, true); }, every);
    }

    function init() {
      document.querySelectorAll('[data-tab-panel-slide]').forEach(function (el, i) {
        if (el._tps) return;
        el._tps = true;
        setup(el, i);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tab-panel-slide>
  <div>
    <button>Design</button>
    <button>Build</button>
    <button>Ship</button>
  </div>
  <div>
    <div>Design panel…</div>
    <div>Build panel…</div>
    <div>Ship panel…</div>
  </div>
</div>