← Navigation

Curved Tab Bar

A tab bar with a smooth notch scooped out of its top edge. Tap a tab and the notch glides along to it, carrying a round bubble; the tab’s icon floats up into the bubble and its label fades in underneath.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-curved-tab-bar to your nav. Each child is a tab holding an icon (an svg, img or i) and a label.
  3. Leave about 30px of room above it for the bubble.

Options

Add any of these attributes to the same element.

data-colorColour of the bubble. Default #6a0dad.
data-bgColour of the bar. Default #fff.
data-activeIndex of the tab selected at first, counting from 0. Default 0.
data-durationHow long the notch takes to glide. Default 600 ms.
data-autoplayIt moves to the next tab every couple of seconds while in view, pausing when someone taps. Handy for demos.

The bar is drawn as an SVG path and redrawn as the notch moves, so it stays sharp at any width. It fires a tabchange event and sets aria-current="page" on the active tab.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Curved Tab Bar: a tab bar with a smooth notch scooped out of its top edge.
    // Tap a tab and the notch glides along to it, carrying a round bubble; the
    // tab's icon floats up into the bubble and its label fades in underneath.
    // Usage: <nav data-curved-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. Leave about 30px of room above the bar for the bubble.
    // The active tab gets aria-current="page"; a "tabchange" event
    // (e.detail.index) fires on a change.
    // Optional: data-color (bubble colour, default #6a0dad), data-bg (bar
    // colour, default #fff), data-active (index selected at first, default 0),
    // data-duration (ms, default 600), 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.curvedTabBar(nav, index) selects a tab from code.
    var DEFAULTS = { color: '#6a0dad', bg: '#fff', active: 0, duration: 600, every: 2000 };
    var BAR = 64;   // bar height
    var TOP = 28;   // room above the bar for the bubble
    var R = 25;     // bubble radius
    var GAP = 7;    // space between bubble and notch

    var css =
      '.ctb{position:relative;display:flex;padding-top:' + TOP + 'px;isolation:isolate}' +
      '.ctb-shape{position:absolute;left:0;top:0;width:100%;height:100%;z-index:-2;overflow:visible;pointer-events:none;filter:drop-shadow(0 8px 18px rgba(20,10,40,.12))}' +
      '.ctb-bubble{position:absolute;left:0;top:' + (TOP - R) + 'px;width:' + 2 * R + 'px;height:' + 2 * R + 'px;border-radius:50%;z-index:-1;pointer-events:none}' +
      '.ctb-tab{position:relative;flex:1 1 0;min-width:0;height:' + BAR + 'px;cursor:pointer;-webkit-tap-highlight-color:transparent;background:none;border:0;padding:0;font:inherit;color:inherit}' +
      '.ctb-icon{position:absolute;left:50%;top:50%;width:24px;height:24px;margin:-12px 0 0 -12px;display:grid;place-items:center;transition:color .3s}' +
      '.ctb-icon>*{width:100%;height:100%}' +
      '.ctb-label{position:absolute;left:0;right:0;bottom:9px;text-align:center;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // The bar outline with a rounded notch centred on cx. Near the ends the
    // notch's shoulders get narrower and the bar's top corner squarer.
    function path(w, cx) {
      var r = R + GAP, t = TOP, b = TOP + BAR, c = 18;
      var sl = Math.max(r + 2, Math.min(1.9 * r, cx)), sr = Math.max(r + 2, Math.min(1.9 * r, w - cx));
      var cl = Math.max(0, Math.min(c, cx - sl)), cr = Math.max(0, Math.min(c, w - cx - sr));
      return 'M0,' + (t + cl) + 'Q0,' + t + ' ' + cl + ',' + t +
        'L' + (cx - sl) + ',' + t +
        'C' + (cx - r) + ',' + t + ' ' + (cx - r) + ',' + (t + r) + ' ' + cx + ',' + (t + r) +
        'C' + (cx + r) + ',' + (t + r) + ' ' + (cx + r) + ',' + t + ' ' + (cx + sr) + ',' + t +
        'L' + (w - cr) + ',' + t + 'Q' + w + ',' + t + ' ' + w + ',' + (t + cr) +
        'L' + w + ',' + (b - c) + 'Q' + w + ',' + b + ' ' + (w - c) + ',' + b +
        'L' + c + ',' + b + 'Q0,' + b + ' 0,' + (b - c) + 'Z';
    }

    function draw(s) {
      var w = s.el.clientWidth;
      s.svg.setAttribute('viewBox', '0 0 ' + w + ' ' + (TOP + BAR));
      s.path.setAttribute('d', path(w, s.pos.x));
      s.bubble.style.left = (s.pos.x - R) + 'px';
    }

    function centre(s, i) { var t = s.tabs[i]; return t.offsetLeft + t.offsetWidth / 2; }

    // How far an icon rises to sit in the bubble.
    function lift() { return -(BAR / 2); }

    function select(el, i, instant) {
      var s = el._ctb;
      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, p = s.parts[k];
        if (s.a11y) { if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }
        p.icon.style.color = on ? '#fff' : '';
        anime.utils.remove([p.icon, p.label]);
        if (still) {
          anime.utils.set(p.icon, { y: on ? lift() : 0, scale: 1 });
          anime.utils.set(p.label, { opacity: on ? 1 : 0, y: 0 });
        } else if (on) {
          anime.animate(p.icon, { y: [{ to: 6, duration: 140, ease: 'out(2)' }, { to: lift(), duration: 620, ease: 'outBack(1.6)' }], delay: s.duration * 0.3 });
          anime.animate(p.label, { opacity: [0, 1], y: [8, 0], duration: 420, delay: s.duration * 0.5, ease: 'out(3)' });
        } else if (k === prev) {
          anime.animate(p.icon, { y: 0, duration: 420, ease: 'out(3)' });
          anime.animate(p.label, { opacity: 0, y: 6, duration: 200, ease: 'in(2)' });
        }
      });
      var x = centre(s, i);
      if (s.anim) s.anim.pause();
      if (still) { s.pos.x = x; draw(s); }
      else {
        s.anim = anime.animate(s.pos, { x: x, duration: s.duration, ease: 'inOut(3)', onRender: function () { draw(s); } });
        // The bubble dips into the bar as it travels, then pops back up.
        anime.animate(s.bubble, { scale: [1, 0.55, 1], duration: s.duration + 200, ease: 'inOut(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 a11y = !el.closest('[aria-hidden="true"]');
      el.classList.add('ctb');

      var svgNS = 'http://www.w3.org/2000/svg';
      var svg = document.createElementNS(svgNS, 'svg');
      svg.setAttribute('class', 'ctb-shape');
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('preserveAspectRatio', 'none');
      var shape = document.createElementNS(svgNS, 'path');
      shape.setAttribute('fill', d.bg || DEFAULTS.bg);
      svg.appendChild(shape);
      var bubble = document.createElement('span');
      bubble.className = 'ctb-bubble';
      bubble.setAttribute('aria-hidden', 'true');
      var color = d.color || DEFAULTS.color;
      bubble.style.background = color;
      bubble.style.boxShadow = '0 8px 18px rgba(20,10,40,.22)';

      var parts = tabs.map(function (t, k) {
        t.classList.add('ctb-tab');
        if (a11y && t.tagName === 'BUTTON' && !t.getAttribute('type')) t.type = 'button';
        var iconSrc = t.querySelector('svg, img, i');
        var text = t.textContent.replace(/\s+/g, ' ').trim();
        t.textContent = '';
        var icon = document.createElement('span');
        icon.className = 'ctb-icon';
        icon.setAttribute('aria-hidden', 'true');
        if (iconSrc) icon.appendChild(iconSrc);
        var label = document.createElement('span');
        label.className = 'ctb-label';
        label.textContent = text;
        t.appendChild(icon);
        t.appendChild(label);
        if (a11y && !t.getAttribute('aria-label')) t.setAttribute('aria-label', text);
        t.addEventListener('click', function (e) {
          if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
          select(el, k);
        });
        return { icon: icon, label: label };
      });
      el.insertBefore(bubble, el.firstChild);
      el.insertBefore(svg, el.firstChild);

      var s = el._ctb = {
        el: el, tabs: tabs, parts: parts, svg: svg, path: shape, bubble: bubble, a11y: a11y,
        index: -1, pos: { x: 0 }, anim: null, duration: +d.duration || DEFAULTS.duration
      };
      select(el, +d.active || DEFAULTS.active, true);
      if (window.ResizeObserver) new ResizeObserver(function () {
        if (s.anim) s.anim.pause();
        s.pos.x = centre(s, s.index);
        draw(s);
      }).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-curved-tab-bar]').forEach(function (el) {
        if (el._ctb) return;
        setup(el);
      });
    }

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