Feature Beacon
A softly pulsing dot marks something new. Tap it and a tip pops out of the dot explaining the feature; press “Got it” and the beacon pops away for good.
How to use it
- Paste the snippet just before
</head>. - Add
data-feature-beacon="What it does"to the new button, link or panel, anddata-titlefor the tip’s heading. - Add
data-keywith a name of your own so it’s remembered once dismissed.
Options
Add any of these attributes to the same element.
data-title | The tip’s heading. |
data-position | Where the dot sits on the element: top-right, top-left, bottom-right or bottom-left. Default top-right. |
data-placement | Which side the tip opens on: bottom or top. Default bottom. |
data-color | Colour of the dot and button. Default #6a0dad. |
data-key | A localStorage key: once dismissed, it never shows again. Without it, it shows on every visit. |
data-beacon-return | Bring the beacon back this many ms after “Got it”, for demos. |
data-autoplay | Time the tip stays open, in ms: it opens and closes by itself while in view, until someone touches it. |
The dot and tip are added next to the element, not inside it, so a button can carry one; its parent should let them show (no overflow: hidden). The dot is a real button with aria-expanded, focus moves to “Got it” and back, and Esc or a press elsewhere closes the tip. It fires beacondismiss when dismissed. From code: animacious.beacon(el) opens the tip.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Feature Beacon: a softly pulsing dot marks something new. Click the dot
// and a tip pops out of it explaining the feature; "Got it" closes the tip,
// the beacon pops away and it stays away next time.
// Usage: <button data-feature-beacon="Select text and let it suggest a clearer version."
// data-title="New: Rewrite">Rewrite</button>
// Optional: data-title (tip heading), data-position (where the dot sits on
// the element: "top-right", the default, "top-left", "bottom-right",
// "bottom-left"), data-placement ("bottom", the default, or "top": which
// side of the element the tip opens on), data-color (default #6a0dad),
// data-key (localStorage key: once dismissed it never shows again; leave it
// out to show it on every visit), data-beacon-return (ms: for demos, the
// beacon comes back this long after "Got it"), data-autoplay (ms the tip
// stays open: it opens and closes by itself while in view, until someone
// touches it).
// Event: "beacondismiss" on the element after "Got it".
// Script use: animacious.beacon(el) opens the tip, animacious.beacon(el, false) closes it.
var DEFAULTS = { position: 'top-right', placement: 'bottom', color: '#6a0dad', autoplay: 2800 };
var css =
'.bcn{position:absolute;z-index:6;width:22px;height:22px;margin:0;padding:0;border:0;background:none;cursor:pointer;-webkit-tap-highlight-color:transparent;--bcn-c:#6a0dad}' +
'.bcn-dot,.bcn-ring{position:absolute;left:50%;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:var(--bcn-c);pointer-events:none}' +
'.bcn-dot{box-shadow:0 0 0 2px #fff,0 2px 6px rgba(0,0,0,.25)}' +
'.bcn-ring{opacity:0}' +
'.bcn:focus-visible{outline:2px solid var(--bcn-c);outline-offset:2px;border-radius:50%}' +
'.bcn-tip{position:absolute;z-index:7;box-sizing:border-box;width:max-content;max-width:min(260px,calc(100vw - 32px));padding:14px 16px;border-radius:14px;background:#17141f;color:#fff;' +
'box-shadow:0 16px 40px rgba(14,10,28,.28);font:500 13px/1.45 system-ui,sans-serif;text-align:left;visibility:hidden;--bcn-ax:24px;--bcn-c:#6a0dad}' +
'.bcn-tip::before{content:"";position:absolute;left:var(--bcn-ax);top:-6px;width:12px;height:12px;margin-left:-6px;background:inherit;border-radius:2px;transform:rotate(45deg)}' +
'.bcn-tip.bcn-up::before{top:auto;bottom:-6px}' +
'.bcn-tip b{display:block;margin-bottom:3px;font-size:14px}' +
'.bcn-tip p{margin:0 0 10px;opacity:.82}' +
'.bcn-ok{display:inline-block;padding:6px 14px;border:0;border-radius:99px;background:var(--bcn-c);color:#fff;font:inherit;font-weight:700;cursor:pointer}' +
'.bcn-ok:focus-visible{outline:2px solid #fff;outline-offset:2px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var uid = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function seen(key) { try { return key && localStorage.getItem(key); } catch (e) { return null; } }
function save(key) { try { if (key) localStorage.setItem(key, '1'); } catch (e) { /* private mode */ } }
function setup(el) {
var d = el.dataset;
if (seen(d.key)) return null;
var deco = !!el.closest('[aria-hidden="true"]');
var color = d.color || DEFAULTS.color;
var b = { el: el, open: false, gone: false, deco: deco, pos: d.position || DEFAULTS.position, up: d.placement === 'top' };
el._bcn = b;
function mk(tag, cls) {
var n = document.createElement(deco && tag === 'button' ? 'span' : tag);
if (n.tagName === 'BUTTON') n.type = 'button';
n.className = cls;
n.style.setProperty('--bcn-c', color);
return n;
}
// The beacon and tip sit next to the element (so a button can carry one)
// and are placed over it.
var dot = mk('button', 'bcn');
dot.innerHTML = '<span class="bcn-ring"></span><span class="bcn-ring"></span><span class="bcn-dot"></span>';
var tip = mk('div', 'bcn-tip');
if (b.up) tip.classList.add('bcn-up');
if (d.title) { var t = document.createElement('b'); t.textContent = d.title; tip.appendChild(t); }
var p = document.createElement('p');
p.textContent = d.featureBeacon || 'Something new is here.';
tip.appendChild(p);
var ok = mk('button', 'bcn-ok');
ok.textContent = 'Got it';
tip.appendChild(ok);
if (!deco) {
tip.id = 'bcn-tip-' + ++uid;
tip.setAttribute('role', 'dialog');
tip.setAttribute('aria-label', d.title || 'New feature');
dot.setAttribute('aria-label', 'New: ' + (d.title || 'what is this?'));
dot.setAttribute('aria-expanded', 'false');
dot.setAttribute('aria-controls', tip.id);
} else {
dot.setAttribute('aria-hidden', 'true');
tip.setAttribute('aria-hidden', 'true');
}
el.insertAdjacentElement('afterend', tip);
el.insertAdjacentElement('afterend', dot);
b.dot = dot; b.tip = tip; b.ok = ok;
b.rings = Array.from(dot.querySelectorAll('.bcn-ring'));
b.core = dot.querySelector('.bcn-dot');
place(b);
if ('ResizeObserver' in window) new ResizeObserver(function () { place(b); }).observe(el.parentNode);
dot.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); b.auto = false; toggle(b, !b.open, true); });
ok.addEventListener('click', function (e) { e.preventDefault(); dismiss(b, true); });
tip.addEventListener('keydown', function (e) { if (e.key === 'Escape') { toggle(b, false); if (!b.deco) dot.focus(); } });
if (!reduced()) {
b.pulse = anime.animate(b.rings, { scale: [1, 3.2], opacity: [0.55, 0], duration: 1800, delay: anime.stagger(900), loop: true, ease: 'out(2)' });
new IntersectionObserver(function (entries) {
if (!b.pulse || b.gone) return;
if (entries[0].isIntersecting) b.pulse.play(); else b.pulse.pause();
}).observe(el);
}
return b;
}
function place(b) {
if (b.gone) return;
var el = b.el, dot = b.dot;
var x = el.offsetLeft + (b.pos.indexOf('left') > -1 ? 0 : el.offsetWidth) - 11;
var y = el.offsetTop + (b.pos.indexOf('bottom') > -1 ? el.offsetHeight : 0) - 11;
dot.style.left = x + 'px';
dot.style.top = y + 'px';
if (b.open) placeTip(b);
}
function placeTip(b) {
var el = b.el, tip = b.tip;
var cx = parseFloat(b.dot.style.left) + 11;
var host = tip.offsetParent || document.body;
var w = tip.offsetWidth;
var left = cx - 26;
// Keep it inside its container and the screen.
var hostR = host.getBoundingClientRect();
var maxRight = Math.min(host.clientWidth, document.documentElement.clientWidth - hostR.left) - 8;
left = Math.max(Math.max(8, 8 - hostR.left), Math.min(left, maxRight - w));
tip.style.left = left + 'px';
tip.style.top = (b.up ? el.offsetTop - tip.offsetHeight - 12 : el.offsetTop + el.offsetHeight + 12) + 'px';
tip.style.setProperty('--bcn-ax', Math.max(14, Math.min(w - 14, cx - left)) + 'px');
tip.style.transformOrigin = (cx - left) + 'px ' + (b.up ? '100%' : '0');
}
function toggle(b, open, byUser) {
if (b.gone || open === b.open) return;
b.open = open;
var tip = b.tip;
if (!b.deco) b.dot.setAttribute('aria-expanded', open);
anime.utils.remove([tip, b.core]);
if (open) {
tip.style.visibility = 'visible';
placeTip(b);
if (byUser && !b.deco) setTimeout(function () { if (b.open) b.ok.focus({ preventScroll: true }); }, 60);
if (reduced()) return anime.utils.set(tip, { opacity: 1, scale: 1 });
anime.animate(b.core, { scale: [1, 0.7, 1], duration: 360, ease: 'out(2)' });
anime.animate(tip, { opacity: [0, 1], scale: [0.4, 1], y: [b.up ? 8 : -8, 0], duration: 520, ease: 'outBack(1.7)' });
anime.animate(tip.children, { opacity: [0, 1], y: [6, 0], duration: 380, delay: anime.stagger(60, { start: 140 }), ease: 'out(3)' });
return;
}
if (tip.contains(document.activeElement) && !b.deco) b.dot.focus({ preventScroll: true });
var done = function () { if (!b.open) tip.style.visibility = 'hidden'; };
if (reduced()) return done();
anime.animate(tip, { opacity: 0, scale: 0.6, duration: 220, ease: 'in(2)', onComplete: done });
}
function dismiss(b, byUser) {
var d = b.el.dataset;
var hadFocus = b.tip.contains(document.activeElement);
toggle(b, false);
if (!b.byAuto) {
b.gone = true;
save(d.key);
if (hadFocus && !b.deco && b.el.focus) b.el.focus({ preventScroll: true });
b.el.dispatchEvent(new CustomEvent('beacondismiss', { bubbles: true }));
if (b.pulse) b.pulse.pause();
var hide = function () { b.dot.style.visibility = 'hidden'; };
if (reduced()) hide();
else anime.animate(b.dot, { scale: [1, 1.4, 0], duration: 420, delay: 120, ease: 'in(2)', onComplete: hide });
if (d.beaconReturn !== undefined) setTimeout(function () {
b.gone = false;
b.dot.style.visibility = 'visible';
place(b);
if (b.pulse) b.pulse.play();
if (!reduced()) anime.animate(b.dot, { scale: [0, 1], duration: 600, ease: 'outBack(3)' });
}, +d.beaconReturn || 2000);
}
}
// Demo mode: the tip opens and closes by itself.
function autoplay(b, hold) {
var timers = [], visible = false;
b.auto = true;
function later(fn, ms) { timers.push(setTimeout(function () { if (b.auto && visible) fn(); }, ms)); }
function run() {
timers.forEach(clearTimeout); timers = [];
later(function () { toggle(b, true); }, 1400);
later(function () {
if (!reduced()) anime.animate(b.ok, { scale: [1, 0.9, 1], duration: 240 });
b.byAuto = true; dismiss(b); b.byAuto = false;
}, 1400 + hold);
later(run, 2400 + hold);
}
b.el.parentNode.addEventListener('pointerdown', function (e) { if (e.isTrusted) { b.auto = false; timers.forEach(clearTimeout); } });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && b.auto && !b.open) run();
else if (!visible) timers.forEach(clearTimeout);
}, { threshold: 0.4 }).observe(b.el);
}
// A press outside an open tip closes it (without dismissing the beacon).
document.addEventListener('pointerdown', function (e) {
if (!e.isTrusted) return;
document.querySelectorAll('.bcn-tip').forEach(function (tip) {
var el = tip.previousElementSibling && tip.previousElementSibling.previousElementSibling;
var b = el && el._bcn;
if (b && b.open && !tip.contains(e.target) && !b.dot.contains(e.target)) toggle(b, false);
});
});
function init() {
document.querySelectorAll('[data-feature-beacon]').forEach(function (el) {
if (el._bcn !== undefined) return;
el._bcn = null;
var b = setup(el);
if (b && on(el.dataset.autoplay) && !reduced()) autoplay(b, +el.dataset.autoplay || DEFAULTS.autoplay);
});
}
window.animacious = window.animacious || {};
window.animacious.beacon = function (el, open) {
var b = el._bcn || setup(el);
if (b) toggle(b, open !== false, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-feature-beacon="Select some text and it suggests a clearer version."
data-title="New: Rewrite"
data-key="beacon-rewrite">✨ Rewrite</button>