← Navigation

Tab Indicator Slide

Pick a tab and the underline glides across to it, its leading edge setting off first so it stretches a little on the way and tucks in on arrival. Prefer a pill? One attribute swaps the underline for a coloured pill behind the label.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tab-indicator-slide to the element holding your tabs. Each child is a tab.
  3. Listen for its tabchange event to show the matching panel.

Options

Add any of these attributes to the same element.

data-styleunderline or pill. Default underline.
data-activeIndex of the tab selected at first, counting from 0. Default 0.
data-colorColour of the underline or pill. Default #6a0dad.
data-durationHow long the glide takes. Default 520 ms.
data-autoplayIt moves to the next tab every couple of seconds while in view, pausing when someone clicks. Handy for demos.

The script adds role="tablist", role="tab" and aria-selected, and the arrow keys move between tabs. The active tab gets the class tis-on for styling. From code, animacious.tabIndicator(nav, 2) selects the third tab.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tab Indicator Slide: an underline (or a pill behind the label) glides to
    // the tab you pick. The leading edge sets off first and the trailing edge
    // catches up, so it stretches a little on the way, like a caterpillar.
    // Usage: <nav data-tab-indicator-slide><button>Home</button><button>Work</button>…</nav>
    // Every child element is a tab. The script adds role="tablist"/"tab" and
    // aria-selected; it fires a "tabchange" event (e.detail.index) on a change.
    // Optional: data-style ("underline" or "pill", default underline),
    // data-active (index of the tab selected at first, default 0),
    // data-color (indicator colour, default #6a0dad), data-duration (ms, default 520),
    // data-autoplay (moves to the next tab every couple of seconds while in view;
    // it pauses for a while when someone clicks).
    // Script use: animacious.tabIndicator(nav, index) selects a tab from code.
    var DEFAULTS = { style: 'underline', active: 0, color: '#6a0dad', duration: 520, every: 1700 };

    var css =
      '.tis{position:relative;display:inline-flex;align-items:center;isolation:isolate}' +
      '.tis-tab{position:relative;z-index:1;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .3s}' +
      '.tis-bar{position:absolute;left:0;z-index:0;pointer-events:none;will-change:transform,width}' +
      '.tis-underline .tis-bar{bottom:0;height:3px;border-radius:3px}' +
      '.tis-pill .tis-bar{top:0;height:100%;border-radius:999px}' +
      '.tis-pill .tis-tab.tis-on{color:#fff}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function edges(s, i) {
      var t = s.tabs[i];
      if (s.pill) return { l: t.offsetLeft, r: t.offsetLeft + t.offsetWidth };
      // The underline sits under the label, not the tab's padding.
      var cs = getComputedStyle(t);
      var pl = parseFloat(cs.paddingLeft) || 0, pr = parseFloat(cs.paddingRight) || 0;
      return { l: t.offsetLeft + pl, r: t.offsetLeft + t.offsetWidth - pr };
    }

    function draw(s) {
      s.bar.style.transform = 'translateX(' + s.pos.l + 'px)';
      s.bar.style.width = Math.max(0, s.pos.r - s.pos.l) + 'px';
    }

    function select(el, i, instant) {
      var s = el._tis;
      i = (i + s.tabs.length) % s.tabs.length;
      var changed = i !== s.index;
      s.index = i;
      s.tabs.forEach(function (t, k) {
        t.classList.toggle('tis-on', k === i);
        if (s.a11y) {
          t.setAttribute('aria-selected', k === i ? 'true' : 'false');
          t.tabIndex = k === i ? 0 : -1;
        }
      });
      var to = edges(s, i);
      if (s.anim) s.anim.pause();
      if (instant || reduced()) {
        s.pos.l = to.l; s.pos.r = to.r; draw(s);
      } else {
        var right = to.l > s.pos.l;
        var lag = s.duration * 0.22;
        // The edge in the direction of travel leads; the other follows.
        s.anim = anime.animate(s.pos, {
          l: { to: to.l, delay: right ? lag : 0 },
          r: { to: to.r, delay: right ? 0 : lag },
          duration: s.duration,
          ease: 'inOut(3)',
          onRender: function () { draw(s); }
        });
      }
      if (changed && !instant) el.dispatchEvent(new CustomEvent('tabchange', { bubbles: true, detail: { index: i } }));
    }

    function setup(el) {
      var d = el.dataset;
      var tabs = Array.prototype.slice.call(el.children);
      if (!tabs.length) return;
      var pill = (d.style || DEFAULTS.style) === 'pill';
      el.classList.add('tis', pill ? 'tis-pill' : 'tis-underline');
      var a11y = !el.closest('[aria-hidden="true"]');
      var bar = document.createElement('span');
      bar.className = 'tis-bar';
      bar.setAttribute('aria-hidden', 'true');
      bar.style.background = d.color || DEFAULTS.color;
      el.appendChild(bar);
      if (a11y) el.setAttribute('role', 'tablist');
      tabs.forEach(function (t, k) {
        t.classList.add('tis-tab');
        if (a11y) {
          t.setAttribute('role', 'tab');
          if (t.tagName === 'BUTTON' && !t.getAttribute('type')) t.type = 'button';
        }
        t.addEventListener('click', function (e) {
          if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
          select(el, k);
        });
      });
      var s = el._tis = {
        tabs: tabs, bar: bar, pill: pill, a11y: a11y, index: -1, pos: { l: 0, r: 0 }, anim: null,
        duration: +d.duration || DEFAULTS.duration
      };
      select(el, +d.active || DEFAULTS.active, true);

      // Arrow keys move between tabs, as a tab list should.
      el.addEventListener('keydown', function (e) {
        if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
        e.preventDefault();
        select(el, s.index + (e.key === 'ArrowRight' ? 1 : -1));
        s.tabs[s.index].focus();
      });

      // Fonts loading or the page resizing moves the tabs: snap to the new spot.
      if (window.ResizeObserver) new ResizeObserver(function () { select(el, s.index, true); }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var timer = null, idleUntil = 0, canHover = window.matchMedia('(hover: hover)').matches;
        var hold = function () { idleUntil = Date.now() + 8000; };
        el.addEventListener('pointerdown', hold);
        el.addEventListener('keydown', hold);
        var tick = function () {
          if (Date.now() > idleUntil && !(canHover && el.matches(':hover'))) select(el, s.index + 1);
          timer = setTimeout(tick, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-tab-indicator-slide]').forEach(function (el) {
        if (el._tis) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.tabIndicator = function (el, i) { if (el._tis) select(el, i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav data-tab-indicator-slide>
  <button>Home</button>
  <button>Work</button>
  <button>About</button>
</nav>

<!-- a pill instead: -->
<nav data-tab-indicator-slide data-style="pill" data-color="#0f766e">…</nav>