← Navigation

Bottom Tab Bar

A phone-style tab bar. Tap a tab and its icon hops up and lands with a squashy bounce, a soft pill swells in behind it and the label slides out beside the icon, while the old tab quietly tucks its label away.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bottom-tab-bar to your nav. Each child is a tab holding an icon (an svg, img or i) and a label.
  3. Listen for its tabchange event to switch screens.

Options

Add any of these attributes to the same element.

data-colorColour of the active tab. Default #6a0dad.
data-tintColour of the pill behind it. Default a pale version of data-color.
data-activeIndex of the tab selected at first, counting from 0. Default 0.
data-autoplayIt moves to the next tab every couple of seconds while in view, pausing when someone taps. Handy for demos.

Size the icons with CSS (about 22px suits a phone). Folded labels are hidden, so each tab gets its label as an aria-label, and the active one gets aria-current="page".

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bottom Tab Bar: a phone-style tab bar. Tap a tab and its icon hops up and
    // lands with a bounce, a soft pill swells in behind it and the label slides
    // out beside the icon, while the old tab tucks its label away.
    // Usage: <nav data-bottom-tab-bar>
    //          <button><svg>…</svg>Home</button>
    //          <button><svg>…</svg>Search</button> …
    //        </nav>
    // Each child is a tab: its first svg, img or i element is the icon and its
    // text is the label. The active tab gets aria-current="page"; a "tabchange"
    // event (e.detail.index) fires on a change.
    // Optional: data-color (active colour, default #6a0dad), data-tint (pill
    // colour, default a soft version of data-color), data-active (index selected
    // at first, default 0), data-autoplay (moves to the next tab every couple of
    // seconds while in view; it pauses for a while when someone taps).
    // Script use: animacious.bottomTabBar(nav, index) selects a tab from code.
    var DEFAULTS = { color: '#6a0dad', active: 0, every: 1900 };

    var css =
      '.btb{display:flex;align-items:center;justify-content:space-around}' +
      '.btb-tab{position:relative;display:inline-flex;align-items:center;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .3s;isolation:isolate}' +
      '.btb-pill{position:absolute;inset:0;z-index:-1;border-radius:999px;opacity:0;pointer-events:none}' +
      '.btb-icon{display:inline-grid;place-items:center;flex:none;transform-origin:50% 100%}' +
      '.btb-label{display:inline-block;overflow:hidden;white-space:nowrap;width:0;opacity:0}' +
      '.btb-text{display:inline-block;padding-left:.5em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function tint(color) {
      var m = /^#([0-9a-f]{6})$/i.exec(color);
      if (!m) return 'rgba(106,13,173,.12)';
      var n = parseInt(m[1], 16);
      return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',.13)';
    }

    function select(el, i, instant) {
      var s = el._btb;
      i = (i + s.tabs.length) % s.tabs.length;
      if (i === s.index && !instant) return;
      var prev = s.index;
      s.index = i;
      var still = instant || reduced();
      s.tabs.forEach(function (t, k) {
        var on = k === i;
        t.style.color = on ? s.color : '';
        if (s.a11y) { if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }
        var p = s.parts[k];
        anime.utils.remove([p.label, p.pill, p.icon]);
        var w = on ? p.text.offsetWidth : 0;
        if (still) {
          anime.utils.set(p.label, { width: w, opacity: on ? 1 : 0 });
          anime.utils.set(p.pill, { opacity: on ? 1 : 0, scaleX: 1 });
          anime.utils.set(p.icon, { y: 0, scaleX: 1, scaleY: 1 });
          return;
        }
        if (on) {
          anime.animate(p.label, { width: w, duration: 480, ease: 'out(4)' });
          anime.animate(p.label, { opacity: 1, duration: 300, delay: 120, ease: 'out(2)' });
          anime.animate(p.pill, { opacity: [0, 1], scaleX: [0.5, 1], duration: 520, ease: 'outElastic(1, .8)' });
          // Hop up, then land with a squash.
          anime.animate(p.icon, {
            y: [{ to: -9, duration: 190, ease: 'out(2)' }, { to: 0, duration: 520, ease: 'outBounce' }],
            scaleY: [{ to: 1.12, duration: 190 }, { to: 0.86, duration: 120, delay: 260 }, { to: 1, duration: 300, ease: 'outElastic(1, .5)' }],
            scaleX: [{ to: 0.9, duration: 190 }, { to: 1.12, duration: 120, delay: 260 }, { to: 1, duration: 300, ease: 'outElastic(1, .5)' }]
          });
        } else if (k === prev) {
          anime.animate(p.label, { width: 0, opacity: 0, duration: 360, ease: 'inOut(3)' });
          anime.animate(p.pill, { opacity: 0, scaleX: 0.7, duration: 300, ease: 'in(2)' });
        }
      });
      if (!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 color = d.color || DEFAULTS.color;
      var a11y = !el.closest('[aria-hidden="true"]');
      el.classList.add('btb');
      var parts = tabs.map(function (t, k) {
        t.classList.add('btb-tab');
        if (a11y && t.tagName === 'BUTTON' && !t.getAttribute('type')) t.type = 'button';
        var iconSrc = t.querySelector('svg, img, i');
        var label = t.textContent.replace(/\s+/g, ' ').trim();
        var icon = document.createElement('span');
        icon.className = 'btb-icon';
        icon.setAttribute('aria-hidden', 'true');
        if (iconSrc) icon.appendChild(iconSrc);
        t.textContent = '';
        var pill = document.createElement('span');
        pill.className = 'btb-pill';
        pill.style.background = d.tint || tint(color);
        var wrap = document.createElement('span');
        wrap.className = 'btb-label';
        var text = document.createElement('span');
        text.className = 'btb-text';
        text.textContent = label;
        wrap.appendChild(text);
        t.appendChild(pill);
        t.appendChild(icon);
        t.appendChild(wrap);
        // The label is hidden while folded, so name the tab for screen readers.
        if (a11y && !t.getAttribute('aria-label')) t.setAttribute('aria-label', label);
        t.addEventListener('click', function (e) {
          if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
          select(el, k);
        });
        return { icon: icon, pill: pill, label: wrap, text: text };
      });
      var s = el._btb = { tabs: tabs, parts: parts, color: color, a11y: a11y, index: -1 };
      select(el, +d.active || DEFAULTS.active, true);
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { select(el, s.index, true); });

      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-bottom-tab-bar]').forEach(function (el) {
        if (el._btb) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.bottomTabBar = function (el, i) { if (el._btb) select(el, i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav data-bottom-tab-bar>
  <button><svg>…</svg>Home</button>
  <button><svg>…</svg>Search</button>
  <button><svg>…</svg>Saved</button>
  <button><svg>…</svg>Profile</button>
</nav>