Tooltip Pop
A tooltip pops out of its anchor with a little bounce, whether you point at it or reach it with the keyboard, and tucks back in when you leave. It flips to the other side when there’s no room.
Point at a button or Tab to it.
How to use it
- Paste the snippet just before
</head>. - Add
data-tooltip-pop="Your tip"to a button, link or anything focusable.
Options
Add any of these attributes to the same element.
data-placement | top (default), bottom, left or right. |
data-delay | Wait before it shows on hover. Default 80 ms. |
The tip is linked with aria-describedby, so screen readers read it after the anchor’s name (an icon with no name of its own is labelled by it instead). Esc hides it, and the pointer can move onto it without it vanishing. On touch screens a long press shows it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tooltip Pop: a tooltip pops out of its anchor with a little bounce when
// you point at it or reach it with the keyboard, and tucks back in when
// you leave. It flips to the other side if there's no room.
// Usage:
// <button data-tooltip-pop="Copy link">Copy</button>
// <button data-tooltip-pop="Bold (Ctrl+B)" aria-label="Bold">B</button>
// The tooltip describes its anchor (aria-describedby), so screen readers
// read it after the anchor's own name. An anchor with no name of its own
// (an icon with no text or aria-label) is labelled by it instead.
// Esc hides it, and you can move the pointer onto it without it vanishing.
// Optional: data-placement (top, bottom, left or right; default top),
// data-delay (ms before it shows, default 80), data-autoplay (anchors next
// to each other with it take turns showing their tooltips while on screen,
// for previews).
var DEFAULTS = { placement: 'top', delay: 80, gap: 10 };
var SIDES = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
var uid = 0;
var shown = [];
var css =
'.ttp{position:fixed;left:0;top:0;z-index:2147483000;max-width:min(260px,calc(100vw - 16px));padding:7px 12px;border-radius:9px;' +
'background:#15131f;color:#fff;font:600 13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;letter-spacing:0;text-align:center;' +
'box-shadow:0 10px 28px rgba(15,15,30,.24);opacity:0;visibility:hidden;pointer-events:auto}' +
// The arrow points at the anchor.
'.ttp::after{content:"";position:absolute;width:10px;height:10px;background:inherit;border-radius:2px;transform:rotate(45deg)}' +
'.ttp[data-side="top"]::after{left:calc(50% - 5px + var(--ttp-shift,0px));bottom:-4px}' +
'.ttp[data-side="bottom"]::after{left:calc(50% - 5px + var(--ttp-shift,0px));top:-4px}' +
'.ttp[data-side="left"]::after{top:calc(50% - 5px);right:-4px}' +
'.ttp[data-side="right"]::after{top:calc(50% - 5px);left:-4px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Picks the preferred side, or the opposite one when it doesn't fit, and
// keeps the tooltip on screen.
function place(t) {
var a = t.anchor.getBoundingClientRect();
var w = t.tip.offsetWidth, h = t.tip.offsetHeight, g = DEFAULTS.gap;
var vw = document.documentElement.clientWidth, vh = window.innerHeight;
var side = t.placement;
var room = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
var need = side === 'top' || side === 'bottom' ? h + g : w + g;
if (room[side] < need && room[SIDES[side]] > room[side]) side = SIDES[side];
var x, y;
if (side === 'top' || side === 'bottom') {
x = a.left + a.width / 2 - w / 2;
y = side === 'top' ? a.top - g - h : a.bottom + g;
} else {
x = side === 'left' ? a.left - g - w : a.right + g;
y = a.top + a.height / 2 - h / 2;
}
var cx = Math.max(8, Math.min(vw - w - 8, x));
t.tip.style.setProperty('--ttp-shift', Math.max(-w / 2 + 12, Math.min(w / 2 - 12, x - cx)) + 'px');
t.tip.style.left = cx + 'px';
t.tip.style.top = Math.max(8, Math.min(vh - h - 8, y)) + 'px';
t.tip.dataset.side = side;
// Grow out of the edge that faces the anchor.
t.tip.style.transformOrigin = { top: '50% 100%', bottom: '50% 0', left: '100% 50%', right: '0 50%' }[side];
return side;
}
function show(t) {
clearTimeout(t.timer);
if (t.on) return;
t.on = true;
shown.push(t);
t.tip.style.visibility = 'visible';
var side = place(t);
var off = { top: [0, 6], bottom: [0, -6], left: [6, 0], right: [-6, 0] }[side];
anime.utils.remove(t.tip);
if (reduced()) { anime.utils.set(t.tip, { opacity: 1, scale: 1, x: 0, y: 0 }); return; }
anime.animate(t.tip, { opacity: { to: [0, 1], duration: 140, ease: 'out(2)' }, scale: [0.5, 1], x: [off[0], 0], y: [off[1], 0], duration: 520, ease: 'outElastic(1, .55)' });
}
function hide(t, now) {
clearTimeout(t.timer);
if (!t.on) return;
t.on = false;
shown = shown.filter(function (s) { return s !== t; });
anime.utils.remove(t.tip);
var done = function () { if (!t.on) t.tip.style.visibility = 'hidden'; };
if (now || reduced()) { anime.utils.set(t.tip, { opacity: 0 }); done(); return; }
anime.animate(t.tip, { opacity: 0, scale: 0.85, duration: 130, ease: 'in(2)', onComplete: done });
}
function setup(a) {
var d = a.dataset;
var inThumb = !!a.closest('[aria-hidden="true"]');
var tip = document.createElement('div');
tip.className = 'ttp';
tip.id = 'ttp-' + uid++;
tip.setAttribute('role', 'tooltip');
tip.textContent = a.getAttribute('data-tooltip-pop');
document.body.appendChild(tip);
var t = { anchor: a, tip: tip, on: false, timer: null, placement: SIDES[d.placement] ? d.placement : DEFAULTS.placement };
a._ttp = t;
if (inThumb) tip.setAttribute('aria-hidden', 'true');
else {
var named = a.getAttribute('aria-label') || a.getAttribute('aria-labelledby') || a.textContent.trim();
a.setAttribute(named ? 'aria-describedby' : 'aria-labelledby', ((a.getAttribute('aria-describedby') || '') + ' ' + tip.id).trim());
// A title would show a second, native tooltip.
if (a.title) a.removeAttribute('title');
}
var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
var later = function (fn, ms) { clearTimeout(t.timer); t.timer = setTimeout(fn, ms); };
a.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { a._ttpUser = true; later(function () { show(t); }, delay); } });
a.addEventListener('pointerleave', function () { a._ttpUser = false; if (document.activeElement !== a) later(function () { hide(t); }, 120); });
// The pointer can cross onto the tooltip (to read or select it).
tip.addEventListener('pointerenter', function () { if (t.on) clearTimeout(t.timer); });
tip.addEventListener('pointerleave', function () { if (document.activeElement !== a) later(function () { hide(t); }, 120); });
a.addEventListener('focus', function () { if (!inThumb) show(t); });
a.addEventListener('blur', function () { hide(t); });
// Touch: a long press shows it, lifting the finger hides it soon after.
a.addEventListener('contextmenu', function (e) { if (t.on) e.preventDefault(); });
a.addEventListener('pointerdown', function (e) { if (e.pointerType === 'touch') later(function () { show(t); }, 450); });
a.addEventListener('pointerup', function (e) { if (e.pointerType === 'touch') later(function () { hide(t); }, t.on ? 1400 : 0); });
}
// Anchors with data-autoplay that share a parent take turns.
function autoplay(group) {
var i = 0, timer = null, every = +group[0].dataset.autoplay || 1500;
var box = group[0].parentNode;
new IntersectionObserver(function (entries) {
clearInterval(timer);
group.forEach(function (a) { hide(a._ttp, true); });
if (!entries[0].isIntersecting) return;
timer = setInterval(function () {
if (box.closest('[hidden]') || group.some(function (a) { return a._ttpUser; })) return;
var t = group[i++ % (group.length + 1)];
group.forEach(function (a) { if (a._ttp !== (t && t._ttp)) hide(a._ttp); });
// Every few turns, a rest with nothing showing.
if (t) show(t._ttp);
}, every);
}, { threshold: 0.6 }).observe(box);
}
function init() {
var groups = new Map();
document.querySelectorAll('[data-tooltip-pop]').forEach(function (a) {
if (a._ttp) return;
setup(a);
if (a.dataset.autoplay !== undefined && a.dataset.autoplay !== 'false' && !reduced()) {
if (!groups.has(a.parentNode)) groups.set(a.parentNode, []);
groups.get(a.parentNode).push(a);
}
});
groups.forEach(autoplay);
// Esc dismisses whatever is showing, without moving focus.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && shown.length) shown.slice().forEach(function (t) { hide(t); });
});
// Follow the anchor while the page (or a box) scrolls.
var frame = 0;
var follow = function () {
if (frame || !shown.length) return;
frame = requestAnimationFrame(function () { frame = 0; shown.forEach(place); });
};
document.addEventListener('scroll', follow, { capture: true, passive: true });
window.addEventListener('resize', follow);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-tooltip-pop="Copy link">Copy</button>
<!-- an icon button, tip underneath: -->
<button aria-label="Bold" data-tooltip-pop="Bold · Ctrl+B"
data-placement="bottom">B</button>