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
- Paste the snippet just before
</head>. - Add
data-tab-indicator-slideto the element holding your tabs. Each child is a tab. - Listen for its
tabchangeevent to show the matching panel.
Options
Add any of these attributes to the same element.
data-style | underline or pill. Default underline. |
data-active | Index of the tab selected at first, counting from 0. Default 0. |
data-color | Colour of the underline or pill. Default #6a0dad. |
data-duration | How long the glide takes. Default 520 ms. |
data-autoplay | It 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.
<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><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>