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
- Paste the snippet just before
</head>. - Add
data-bottom-tab-barto your nav. Each child is a tab holding an icon (ansvg,imgori) and a label. - Listen for its
tabchangeevent to switch screens.
Options
Add any of these attributes to the same element.
data-color | Colour of the active tab. Default #6a0dad. |
data-tint | Colour of the pill behind it. Default a pale version of data-color. |
data-active | Index of the tab selected at first, counting from 0. Default 0. |
data-autoplay | It 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".
<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><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>