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
- Paste the snippet just before
</head>. - Add
data-curved-tab-barto your nav. Each child is a tab holding an icon (ansvg,imgori) and a label. - Leave about 30px of room above it for the bubble.
Options
Add any of these attributes to the same element.
data-color | Colour of the bubble. Default #6a0dad. |
data-bg | Colour of the bar. Default #fff. |
data-active | Index of the tab selected at first, counting from 0. Default 0. |
data-duration | How long the notch takes to glide. Default 600 ms. |
data-autoplay | It 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.
<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><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>